diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index cf02a1212..7c19ece4f 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -231,6 +231,10 @@ func (callback *Callback) Value(key string) *PathItem Value returns the callback for key or nil type CallbackRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Callback @@ -430,6 +434,10 @@ func (example *Example) Validate(ctx context.Context, opts ...ValidationOption) Validate returns an error if Example does not comply with the OpenAPI spec. type ExampleRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Example @@ -528,6 +536,10 @@ func (header *Header) Validate(ctx context.Context, opts ...ValidationOption) er Validate returns an error if Header does not comply with the OpenAPI spec. type HeaderRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Header @@ -641,6 +653,10 @@ func (link *Link) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Link does not comply with the OpenAPI spec. type LinkRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Link @@ -963,6 +979,10 @@ func (parameter *Parameter) WithRequired(value bool) *Parameter func (parameter *Parameter) WithSchema(value *Schema) *Parameter type ParameterRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Parameter @@ -1207,6 +1227,10 @@ func (requestBody *RequestBody) WithSchema(value *Schema, consumes []string) *Re func (requestBody *RequestBody) WithSchemaRef(value *SchemaRef, consumes []string) *RequestBody type RequestBodyRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *RequestBody @@ -1282,6 +1306,10 @@ func (m ResponseBodies) JSONLookup(token string) (any, error) https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable type ResponseRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Response @@ -1588,6 +1616,10 @@ func (err *SchemaError) JSONPointer() []string func (err SchemaError) Unwrap() error type SchemaRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Schema @@ -1748,6 +1780,10 @@ func (ss *SecurityScheme) WithScheme(value string) *SecurityScheme func (ss *SecurityScheme) WithType(value string) *SecurityScheme type SecuritySchemeRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *SecurityScheme @@ -1968,6 +2004,12 @@ type ValidationOption func(options *ValidationOptions) ValidationOption allows the modification of how the OpenAPI document is validated. +func AllowExtensionsWithRef() ValidationOption + AllowExtensionsWithRef allows extensions (fields starting with 'x-') as + siblings for $ref fields. This is the default. Non-extension fields are + prohibited unless allowed explicitly with the AllowExtraSiblingFields + option. + func AllowExtraSiblingFields(fields ...string) ValidationOption AllowExtraSiblingFields called as AllowExtraSiblingFields("description") makes Validate not return an error when said field appears next to a $ref. @@ -2008,6 +2050,12 @@ func EnableSchemaPatternValidation() ValidationOption DisableSchemaPatternValidation. By default, schema pattern validation is enabled. +func ProhibitExtensionsWithRef() ValidationOption + ProhibitExtensionsWithRef causes the validation to return an error if + extensions (fields starting with 'x-') are found as siblings for $ref + fields. Non-extension fields are prohibited unless allowed explicitly with + the AllowExtraSiblingFields option. + type ValidationOptions struct { // Has unexported fields. } diff --git a/openapi3/refs.go b/openapi3/refs.go index c818d9d00..9374fb2b2 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "sort" + "strings" "github.com/go-openapi/jsonpointer" "github.com/perimeterx/marshmallow" @@ -15,6 +16,10 @@ import ( // CallbackRef represents either a Callback or a $ref to a Callback. // When serializing and both fields are set, Ref is preferred over Value. type CallbackRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Callback extra []string @@ -63,6 +68,14 @@ func (x *CallbackRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -72,8 +85,9 @@ func (x *CallbackRef) UnmarshalJSON(data []byte) error { // Validate returns an error if CallbackRef does not comply with the OpenAPI spec. func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -81,15 +95,33 @@ func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) er continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -98,6 +130,11 @@ func (x *CallbackRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -105,6 +142,10 @@ func (x *CallbackRef) JSONLookup(token string) (any, error) { // ExampleRef represents either a Example or a $ref to a Example. // When serializing and both fields are set, Ref is preferred over Value. type ExampleRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Example extra []string @@ -153,6 +194,14 @@ func (x *ExampleRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -162,8 +211,9 @@ func (x *ExampleRef) UnmarshalJSON(data []byte) error { // Validate returns an error if ExampleRef does not comply with the OpenAPI spec. func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -171,15 +221,33 @@ func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) err continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -188,6 +256,11 @@ func (x *ExampleRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -195,6 +268,10 @@ func (x *ExampleRef) JSONLookup(token string) (any, error) { // HeaderRef represents either a Header or a $ref to a Header. // When serializing and both fields are set, Ref is preferred over Value. type HeaderRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Header extra []string @@ -243,6 +320,14 @@ func (x *HeaderRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -252,8 +337,9 @@ func (x *HeaderRef) UnmarshalJSON(data []byte) error { // Validate returns an error if HeaderRef does not comply with the OpenAPI spec. func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -261,15 +347,33 @@ func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) erro continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -278,6 +382,11 @@ func (x *HeaderRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -285,6 +394,10 @@ func (x *HeaderRef) JSONLookup(token string) (any, error) { // LinkRef represents either a Link or a $ref to a Link. // When serializing and both fields are set, Ref is preferred over Value. type LinkRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Link extra []string @@ -333,6 +446,14 @@ func (x *LinkRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -342,8 +463,9 @@ func (x *LinkRef) UnmarshalJSON(data []byte) error { // Validate returns an error if LinkRef does not comply with the OpenAPI spec. func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -351,15 +473,33 @@ func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -368,6 +508,11 @@ func (x *LinkRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -375,6 +520,10 @@ func (x *LinkRef) JSONLookup(token string) (any, error) { // ParameterRef represents either a Parameter or a $ref to a Parameter. // When serializing and both fields are set, Ref is preferred over Value. type ParameterRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Parameter extra []string @@ -423,6 +572,14 @@ func (x *ParameterRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -432,8 +589,9 @@ func (x *ParameterRef) UnmarshalJSON(data []byte) error { // Validate returns an error if ParameterRef does not comply with the OpenAPI spec. func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -441,15 +599,33 @@ func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) e continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -458,6 +634,11 @@ func (x *ParameterRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -465,6 +646,10 @@ func (x *ParameterRef) JSONLookup(token string) (any, error) { // RequestBodyRef represents either a RequestBody or a $ref to a RequestBody. // When serializing and both fields are set, Ref is preferred over Value. type RequestBodyRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *RequestBody extra []string @@ -513,6 +698,14 @@ func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -522,8 +715,9 @@ func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { // Validate returns an error if RequestBodyRef does not comply with the OpenAPI spec. func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -531,15 +725,33 @@ func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -548,6 +760,11 @@ func (x *RequestBodyRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -555,6 +772,10 @@ func (x *RequestBodyRef) JSONLookup(token string) (any, error) { // ResponseRef represents either a Response or a $ref to a Response. // When serializing and both fields are set, Ref is preferred over Value. type ResponseRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Response extra []string @@ -603,6 +824,14 @@ func (x *ResponseRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -612,8 +841,9 @@ func (x *ResponseRef) UnmarshalJSON(data []byte) error { // Validate returns an error if ResponseRef does not comply with the OpenAPI spec. func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -621,15 +851,33 @@ func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) er continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -638,6 +886,11 @@ func (x *ResponseRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -645,6 +898,10 @@ func (x *ResponseRef) JSONLookup(token string) (any, error) { // SchemaRef represents either a Schema or a $ref to a Schema. // When serializing and both fields are set, Ref is preferred over Value. type SchemaRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *Schema extra []string @@ -693,6 +950,14 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -702,8 +967,9 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { // Validate returns an error if SchemaRef does not comply with the OpenAPI spec. func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -711,15 +977,33 @@ func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) erro continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -728,6 +1012,11 @@ func (x *SchemaRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } @@ -735,6 +1024,10 @@ func (x *SchemaRef) JSONLookup(token string) (any, error) { // SecuritySchemeRef represents either a SecurityScheme or a $ref to a SecurityScheme. // When serializing and both fields are set, Ref is preferred over Value. type SecuritySchemeRef struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *SecurityScheme extra []string @@ -783,6 +1076,14 @@ func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -792,8 +1093,9 @@ func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { // Validate returns an error if SecuritySchemeRef does not comply with the OpenAPI spec. func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -801,15 +1103,33 @@ func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOpti continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -818,6 +1138,11 @@ func (x *SecuritySchemeRef) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } diff --git a/openapi3/refs.tmpl b/openapi3/refs.tmpl index af8edb1fe..a965bd1e1 100644 --- a/openapi3/refs.tmpl +++ b/openapi3/refs.tmpl @@ -7,6 +7,7 @@ import ( "fmt" "net/url" "sort" + "strings" "github.com/go-openapi/jsonpointer" "github.com/perimeterx/marshmallow" @@ -15,6 +16,10 @@ import ( // {{ $type.Name }}Ref represents either a {{ $type.Name }} or a $ref to a {{ $type.Name }}. // When serializing and both fields are set, Ref is preferred over Value. type {{ $type.Name }}Ref struct { + // Extensions only captures fields starting with 'x-' as no other fields + // are allowed by the openapi spec. + Extensions map[string]any + Ref string Value *{{ $type.Name }} extra []string @@ -63,6 +68,14 @@ func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { x.extra = append(x.extra, key) } sort.Strings(x.extra) + for k := range extra { + if !strings.HasPrefix(k, "x-") { + delete(extra, k) + } + } + if len(extra) != 0 { + x.Extensions = extra + } } return nil } @@ -72,8 +85,9 @@ func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { // Validate returns an error if {{ $type.Name }}Ref does not comply with the OpenAPI spec. func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOption) error { ctx = WithValidationOptions(ctx, opts...) + exProhibited := getValidationOptions(ctx).schemaExtensionsInRefProhibited + var extras []string if extra := x.extra; len(extra) != 0 { - extras := make([]string, 0, len(extra)) allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed for _, ex := range extra { if allowed != nil { @@ -81,15 +95,33 @@ func (x *{{ $type.Name }}Ref) Validate(ctx context.Context, opts ...ValidationOp continue } } - extras = append(extras, ex) + // extras in the Extensions checked below + if _, ok := x.Extensions[ex]; !ok { + extras = append(extras, ex) + } } - if len(extras) != 0 { - return fmt.Errorf("extra sibling fields: %+v", extras) + } + + if extra := x.Extensions; exProhibited && len(extra) != 0 { + allowed := getValidationOptions(ctx).extraSiblingFieldsAllowed + for ex := range extra { + if allowed != nil { + if _, ok := allowed[ex]; ok { + continue + } + } + extras = append(extras, ex) } } + + if len(extras) != 0 { + return fmt.Errorf("extra sibling fields: %+v", extras) + } + if v := x.Value; v != nil { return v.Validate(ctx) } + return foundUnresolvedRef(x.Ref) } @@ -98,6 +130,11 @@ func (x *{{ $type.Name }}Ref) JSONLookup(token string) (any, error) { if token == "$ref" { return x.Ref, nil } + + if v, ok := x.Extensions[token]; ok { + return v, nil + } + ptr, _, err := jsonpointer.GetForToken(x.Value, token) return ptr, err } diff --git a/openapi3/refs_issue222_test.go b/openapi3/refs_issue222_test.go new file mode 100644 index 000000000..646d48751 --- /dev/null +++ b/openapi3/refs_issue222_test.go @@ -0,0 +1,113 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue222(t *testing.T) { + spec := []byte(` +openapi: 3.0.0 +info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT +servers: + - url: 'http://petstore.swagger.io/v1' +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': # <--------------- PANIC HERE + + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '/pets/{petId}': + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string +`[1:]) + + loader := NewLoader() + doc, err := loader.LoadFromData(spec) + require.EqualError(t, err, `invalid response: value MUST be an object`) + require.Nil(t, doc) +} diff --git a/openapi3/refs_issue247_test.go b/openapi3/refs_issue247_test.go new file mode 100644 index 000000000..62f056d87 --- /dev/null +++ b/openapi3/refs_issue247_test.go @@ -0,0 +1,228 @@ +package openapi3 + +import ( + "reflect" + "testing" + + "github.com/go-openapi/jsonpointer" + "github.com/stretchr/testify/require" +) + +func TestIssue247(t *testing.T) { + spec := []byte(` +openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.5 +servers: +- url: /api/v3 +tags: +- name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io +- name: store + description: Operations about user +- name: user + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + OneOfTest: + type: object + oneOf: + - type: string + - type: integer + format: int32 +`[1:]) + + loader := NewLoader() + doc, err := loader.LoadFromData(spec) + require.NoError(t, err) + require.NotNil(t, doc) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + var ptr jsonpointer.Pointer + var v interface{} + var kind reflect.Kind + + ptr, err = jsonpointer.New("/paths") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Paths{}, v) + require.Equal(t, reflect.TypeOf(&Paths{}).Kind(), kind) + + ptr, err = jsonpointer.New("/paths/~1pet") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &PathItem{}, v) + require.Equal(t, reflect.TypeOf(&PathItem{}).Kind(), kind) + + ptr, err = jsonpointer.New("/paths/~1pet/put") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Operation{}, v) + require.Equal(t, reflect.TypeOf(&Operation{}).Kind(), kind) + + ptr, err = jsonpointer.New("/paths/~1pet/put/responses") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Responses{}, v) + require.Equal(t, reflect.TypeOf(&Responses{}).Kind(), kind) + + ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Response{}, v) + require.Equal(t, reflect.TypeOf(&Response{}).Kind(), kind) + + ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200/content") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, Content{}, v) + require.Equal(t, reflect.TypeOf(Content{}).Kind(), kind) + + ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200/content/application~1json/schema") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Ref{}, v) + require.Equal(t, reflect.Ptr, kind) + require.Equal(t, "#/components/schemas/Pet", v.(*Ref).Ref) + + ptr, err = jsonpointer.New("/components/schemas/Pets/items") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Ref{}, v) + require.Equal(t, reflect.Ptr, kind) + require.Equal(t, "#/components/schemas/Pet", v.(*Ref).Ref) + + ptr, err = jsonpointer.New("/components/schemas/Error/properties/code") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Schema{}, v) + require.Equal(t, reflect.Ptr, kind) + require.Equal(t, &Types{"integer"}, v.(*Schema).Type) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/0") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Schema{}, v) + require.Equal(t, reflect.Ptr, kind) + require.Equal(t, &Types{"string"}, v.(*Schema).Type) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/1") + require.NoError(t, err) + v, kind, err = ptr.Get(doc) + require.NoError(t, err) + require.NotNil(t, v) + require.IsType(t, &Schema{}, v) + require.Equal(t, reflect.Ptr, kind) + require.Equal(t, &Types{"integer"}, v.(*Schema).Type) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/5") + require.NoError(t, err) + _, _, err = ptr.Get(doc) + require.Error(t, err) + + ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/-1") + require.NoError(t, err) + _, _, err = ptr.Get(doc) + require.Error(t, err) +} diff --git a/openapi3/refs_test.go b/openapi3/refs_test.go index fc1151b27..b6de316f0 100644 --- a/openapi3/refs_test.go +++ b/openapi3/refs_test.go @@ -1,334 +1,354 @@ +// Code generated by go generate; DO NOT EDIT. package openapi3 import ( - "reflect" + "context" + "encoding/json" "testing" - "github.com/go-openapi/jsonpointer" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestIssue222(t *testing.T) { - spec := []byte(` -openapi: 3.0.0 -info: - version: 1.0.0 - title: Swagger Petstore - license: - name: MIT -servers: - - url: 'http://petstore.swagger.io/v1' -paths: - /pets: - get: - summary: List all pets - operationId: listPets - tags: - - pets - parameters: - - name: limit - in: query - description: How many items to return at one time (max 100) - required: false - schema: - type: integer - format: int32 - responses: - '200': # <--------------- PANIC HERE - - post: - summary: Create a pet - operationId: createPets - tags: - - pets - responses: - '201': - description: Null response - default: - description: unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '/pets/{petId}': - get: - summary: Info for a specific pet - operationId: showPetById - tags: - - pets - parameters: - - name: petId - in: path - required: true - description: The id of the pet to retrieve - schema: - type: string - responses: - '200': - description: Expected response to a valid request - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - default: - description: unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' -components: - schemas: - Pet: - type: object - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - tag: - type: string - Pets: - type: array - items: - $ref: '#/components/schemas/Pet' - Error: - type: object - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string -`[1:]) - - loader := NewLoader() - doc, err := loader.LoadFromData(spec) - require.EqualError(t, err, `invalid response: value MUST be an object`) - require.Nil(t, doc) +func TestCallbackRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := CallbackRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &Callback{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +} + +func TestExampleRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := ExampleRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &Example{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +} + +func TestHeaderRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := HeaderRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + // Header does not have its own extensions. +} + +func TestLinkRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := LinkRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &Link{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +} + +func TestParameterRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := ParameterRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &Parameter{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +} + +func TestRequestBodyRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := RequestBodyRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &RequestBody{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) } -func TestIssue247(t *testing.T) { - spec := []byte(` -openapi: 3.0.2 -info: - title: Swagger Petstore - OpenAPI 3.0 - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - version: 1.0.5 -servers: -- url: /api/v3 -tags: -- name: pet - description: Everything about your Pets - externalDocs: - description: Find out more - url: http://swagger.io -- name: store - description: Operations about user -- name: user - description: Access to Petstore orders - externalDocs: - description: Find out more about our store - url: http://swagger.io -paths: - /pet: - put: - tags: - - pet - summary: Update an existing pet - description: Update an existing pet by Id - operationId: updatePet - requestBody: - description: Update an existent pet in the store - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/Pet' - required: true - responses: - "200": - description: Successful operation - content: - application/xml: - schema: - $ref: '#/components/schemas/Pet' - application/json: - schema: - $ref: '#/components/schemas/Pet' - "400": - description: Invalid ID supplied - "404": - description: Pet not found - "405": - description: Validation exception - security: - - petstore_auth: - - write:pets - - read:pets -components: - schemas: - Pet: - type: object - required: - - id - - name - properties: - id: - type: integer - format: int64 - name: - type: string - tag: - type: string - Pets: - type: array - items: - $ref: '#/components/schemas/Pet' - Error: - type: object - required: - - code - - message - properties: - code: - type: integer - format: int32 - message: - type: string - OneOfTest: - type: object - oneOf: - - type: string - - type: integer - format: int32 -`[1:]) - - loader := NewLoader() - doc, err := loader.LoadFromData(spec) - require.NoError(t, err) - require.NotNil(t, doc) - - err = doc.Validate(loader.Context) - require.NoError(t, err) - - var ptr jsonpointer.Pointer - var v any - var kind reflect.Kind - - ptr, err = jsonpointer.New("/paths") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Paths{}, v) - require.Equal(t, reflect.TypeOf(&Paths{}).Kind(), kind) - - ptr, err = jsonpointer.New("/paths/~1pet") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &PathItem{}, v) - require.Equal(t, reflect.TypeOf(&PathItem{}).Kind(), kind) - - ptr, err = jsonpointer.New("/paths/~1pet/put") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Operation{}, v) - require.Equal(t, reflect.TypeOf(&Operation{}).Kind(), kind) - - ptr, err = jsonpointer.New("/paths/~1pet/put/responses") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Responses{}, v) - require.Equal(t, reflect.TypeOf(&Responses{}).Kind(), kind) - - ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Response{}, v) - require.Equal(t, reflect.TypeOf(&Response{}).Kind(), kind) - - ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200/content") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, Content{}, v) - require.Equal(t, reflect.TypeOf(Content{}).Kind(), kind) - - ptr, err = jsonpointer.New("/paths/~1pet/put/responses/200/content/application~1json/schema") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Ref{}, v) - require.Equal(t, reflect.Ptr, kind) - require.Equal(t, "#/components/schemas/Pet", v.(*Ref).Ref) - - ptr, err = jsonpointer.New("/components/schemas/Pets/items") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Ref{}, v) - require.Equal(t, reflect.Ptr, kind) - require.Equal(t, "#/components/schemas/Pet", v.(*Ref).Ref) - - ptr, err = jsonpointer.New("/components/schemas/Error/properties/code") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Schema{}, v) - require.Equal(t, reflect.Ptr, kind) - require.Equal(t, &Types{"integer"}, v.(*Schema).Type) - - ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/0") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Schema{}, v) - require.Equal(t, reflect.Ptr, kind) - require.Equal(t, &Types{"string"}, v.(*Schema).Type) - - ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/1") - require.NoError(t, err) - v, kind, err = ptr.Get(doc) - require.NoError(t, err) - require.NotNil(t, v) - require.IsType(t, &Schema{}, v) - require.Equal(t, reflect.Ptr, kind) - require.Equal(t, &Types{"integer"}, v.(*Schema).Type) - - ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/5") - require.NoError(t, err) - _, _, err = ptr.Get(doc) - require.Error(t, err) - - ptr, err = jsonpointer.New("/components/schemas/OneOfTest/oneOf/-1") - require.NoError(t, err) - _, _, err = ptr.Get(doc) - require.Error(t, err) +func TestResponseRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := ResponseRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &Response{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +} + +func TestSchemaRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := SchemaRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &Schema{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +} + +func TestSecuritySchemeRef_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := SecuritySchemeRef{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) + + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &SecurityScheme{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) } diff --git a/openapi3/refs_test.tmpl b/openapi3/refs_test.tmpl new file mode 100644 index 000000000..634fccf6f --- /dev/null +++ b/openapi3/refs_test.tmpl @@ -0,0 +1,54 @@ +// Code generated by go generate; DO NOT EDIT. +package {{ .Package }} + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) +{{ range $type := .Types }} +func Test{{ $type.Name }}Ref_Extensions(t *testing.T) { + data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) + + ref := {{ $type.Name }}Ref{} + err := json.Unmarshal(data, &ref) + assert.NoError(t, err) + + // captures extension + assert.Equal(t, "#/components/schemas/Pet", ref.Ref) + assert.Equal(t, float64(1), ref.Extensions["x-order"]) + + // does not capture non-extensions + assert.Nil(t, ref.Extensions["something"]) + + // validation + err = ref.Validate(context.Background()) + require.EqualError(t, err, "extra sibling fields: [something]") + + err = ref.Validate(context.Background(), ProhibitExtensionsWithRef()) + require.EqualError(t, err, "extra sibling fields: [something x-order]") + + err = ref.Validate(context.Background(), AllowExtraSiblingFields("something")) + assert.ErrorContains(t, err, "found unresolved ref") // expected since value not defined + + // non-extension not json lookable + _, err = ref.JSONLookup("something") + assert.Error(t, err) +{{ if ne $type.Name "Header" }} + t.Run("extentions in value", func(t *testing.T) { + ref.Value = &{{ $type.Name }}{Extensions: map[string]any{}} + ref.Value.Extensions["x-order"] = 2.0 + + // prefers the value next to the \$ref over the one in the \$ref. + v, err := ref.JSONLookup("x-order") + assert.NoError(t, err) + assert.Equal(t, float64(1), v) + }) +{{ else }} + // Header does not have its own extensions. +{{ end -}} +} +{{ end -}} diff --git a/openapi3/refsgenerator.go b/openapi3/refsgenerator.go index f3d66bb2f..65c3c88a6 100644 --- a/openapi3/refsgenerator.go +++ b/openapi3/refsgenerator.go @@ -13,8 +13,16 @@ import ( //go:embed refs.tmpl var tmplData string +//go:embed refs_test.tmpl +var tmplTestData string + func main() { - file, err := os.Create("refs.go") + generateTemplate("refs", tmplData) + generateTemplate("refs_test", tmplTestData) +} + +func generateTemplate(filename string, tmpl string) { + file, err := os.Create(filename + ".go") if err != nil { panic(err) } @@ -25,7 +33,7 @@ func main() { } }() - packageTemplate := template.Must(template.New("openapi3-refs").Parse(tmplData)) + packageTemplate := template.Must(template.New("openapi3-" + filename).Parse(tmpl)) type componentType struct { Name string diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index 8982594b5..45563256a 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -12,6 +12,7 @@ type ValidationOptions struct { schemaDefaultsValidationDisabled bool schemaFormatValidationEnabled bool schemaPatternValidationDisabled bool + schemaExtensionsInRefProhibited bool extraSiblingFieldsAllowed map[string]struct{} } @@ -92,6 +93,26 @@ func DisableExamplesValidation() ValidationOption { } } +// AllowExtensionsWithRef allows extensions (fields starting with 'x-') +// as siblings for $ref fields. This is the default. +// Non-extension fields are prohibited unless allowed explicitly with the +// AllowExtraSiblingFields option. +func AllowExtensionsWithRef() ValidationOption { + return func(options *ValidationOptions) { + options.schemaExtensionsInRefProhibited = false + } +} + +// ProhibitExtensionsWithRef causes the validation to return an +// error if extensions (fields starting with 'x-') are found as +// siblings for $ref fields. Non-extension fields are prohibited +// unless allowed explicitly with the AllowExtraSiblingFields option. +func ProhibitExtensionsWithRef() ValidationOption { + return func(options *ValidationOptions) { + options.schemaExtensionsInRefProhibited = true + } +} + // WithValidationOptions allows adding validation options to a context object that can be used when validating any OpenAPI type. func WithValidationOptions(ctx context.Context, opts ...ValidationOption) context.Context { if len(opts) == 0 {