diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b168daa..95bbbc10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ * `XmlNodeTypeBefore32`: detect XML Object `nodeType` usage in pre-3.2 documents (3.2 addition) * `XmlAttributeDeprecation` / `XmlWrappedDeprecation`: detect XML Object `attribute: true` / `wrapped: true` in 3.2 documents (deprecated in 3.2 in favor of `nodeType`) * `SecuritySchemeFieldsBefore32`: detect Security Scheme `deprecated` / `oauth2MetadataUrl` and the `deviceAuthorization` OAuth flow in pre-3.2 documents (3.2 additions) + * `MediaTypesBefore32`: detect `components.mediaTypes` usage in pre-3.2 documents (3.2 addition) * expose the declared version as `OpenAPI#openapi_version` (a `Gem::Version`, or nil when the field is missing or malformed) so `SpecValidator` rules compare version ranges; a 3.2 document is checked by the 3.1-or-later rules * support 3.1-style numeric `exclusiveMinimum` / `exclusiveMaximum` in value validation (standalone bound, not a Boolean modifier on `minimum` / `maximum`) * support `type: "null"` (3.1 primitive) in value validation @@ -35,6 +36,8 @@ * support `const` (OpenAPI 3.1) with exact-equality value validation * support `contentSchema` (OpenAPI 3.1) in the parse layer * support root-level `$self` (OpenAPI 3.2) in the parse layer (`self_uri`); it is not yet used as the base URI for `$ref` resolution +* add `allow_3_2_features` config to apply OpenAPI 3.2 runtime behavior to documents that declare an earlier version (default `false`: pre-3.2 documents behave as before) +* support `components.mediaTypes` (OpenAPI 3.2), resolving `$ref`s in request body and response `content` in 3.2 documents; an unresolved one accepts any body unless `strict_reference_validation` is set ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion diff --git a/lib/openapi_parser.rb b/lib/openapi_parser.rb index 5b6a8ada..626394a5 100644 --- a/lib/openapi_parser.rb +++ b/lib/openapi_parser.rb @@ -40,8 +40,9 @@ def load(filepath, config = {}) end # Load schema located by the passed uri. Uri must be absolute. + # @param [OpenAPIParser::Schemas::OpenAPI, nil] referrer the document whose $ref loads this one # @return [OpenAPIParser::Schemas::OpenAPI] - def load_uri(uri, config:, schema_registry:) + def load_uri(uri, config:, schema_registry:, referrer: nil) # Open-uri doesn't open file scheme uri, so we try to open file path directly # File scheme uri which points to a remote file is not supported. uri_path = uri.path @@ -54,7 +55,7 @@ def load_uri(uri, config:, schema_registry:) end extension = Pathname.new(uri_path).extname - load_hash(parse_file(content, extension), config: config, uri: uri, schema_registry: schema_registry) + load_hash(parse_file(content, extension), config: config, uri: uri, schema_registry: schema_registry, referrer: referrer) end private @@ -90,8 +91,8 @@ def parse_json(content) JSON.parse(content) end - def load_hash(hash, config:, uri:, schema_registry:) - root = Schemas::OpenAPI.new(hash, config, uri: uri, schema_registry: schema_registry) + def load_hash(hash, config:, uri:, schema_registry:, referrer: nil) + root = Schemas::OpenAPI.new(hash, config, uri: uri, schema_registry: schema_registry, referrer: referrer) OpenAPIParser::ReferenceExpander.expand(root, config.strict_reference_validation) if config.expand_reference diff --git a/lib/openapi_parser/concerns/media_type_selectable.rb b/lib/openapi_parser/concerns/media_type_selectable.rb index cdff407a..a8ae4001 100644 --- a/lib/openapi_parser/concerns/media_type_selectable.rb +++ b/lib/openapi_parser/concerns/media_type_selectable.rb @@ -1,4 +1,22 @@ module OpenAPIParser::MediaTypeSelectable + # `content` accepts $refs only where 3.2 runtime behavior applies; before + # 3.2 a `$ref` there is parsed as a MediaType with no schema, as it always was + CONTENT_REFERENCE = ->(target) { target.root.use_3_2_features? } + + # A `content` $ref left unresolved (strict_reference_validation off) falls + # back to a schemaless MediaType instead of staying a Reference + def expand_reference(root, validate_references) + super + + content&.each do |key, media_type| + next unless media_type.kind_of?(OpenAPIParser::Schemas::Reference) + + fallback = OpenAPIParser::Schemas::MediaType.new(media_type.object_reference, self, root, media_type.raw_schema) + _update_child_object(media_type, fallback) + content[key] = fallback + end + end + private # select media type by content_type (consider wild card definition) diff --git a/lib/openapi_parser/concerns/schema_loader/creator.rb b/lib/openapi_parser/concerns/schema_loader/creator.rb index 6de509b6..ca434704 100644 --- a/lib/openapi_parser/concerns/schema_loader/creator.rb +++ b/lib/openapi_parser/concerns/schema_loader/creator.rb @@ -26,6 +26,11 @@ def check_reference_schema?(check_schema) check_object_schema?(check_schema) && !check_schema['$ref'].nil? end + # `reference:` is a Boolean, or a Proc taking the owning object + def allow_reference?(target_object) + @allow_reference.respond_to?(:call) ? @allow_reference.call(target_object) : @allow_reference + end + def check_object_schema?(check_schema) check_schema.kind_of?(::Hash) end @@ -39,7 +44,7 @@ def build_openapi_object_from_option(target_object, ref, schema) if @allow_data_type && !check_object_schema?(schema) schema - elsif @allow_reference && check_reference_schema?(schema) + elsif allow_reference?(target_object) && check_reference_schema?(schema) OpenAPIParser::Schemas::Reference.new(ref, target_object, target_object.root, schema) else @klass.new(ref, target_object, target_object.root, schema) diff --git a/lib/openapi_parser/config.rb b/lib/openapi_parser/config.rb index 850b555b..2e663eeb 100644 --- a/lib/openapi_parser/config.rb +++ b/lib/openapi_parser/config.rb @@ -15,6 +15,11 @@ def allow_empty_date_and_datetime @config.fetch(:allow_empty_date_and_datetime, false) end + # apply OpenAPI 3.2 runtime behavior to documents that declare an earlier version + def allow_3_2_features + @config.fetch(:allow_3_2_features, false) + end + def datetime_coerce_class @config[:datetime_coerce_class] end diff --git a/lib/openapi_parser/schemas/components.rb b/lib/openapi_parser/schemas/components.rb index 431d0425..8a92af26 100644 --- a/lib/openapi_parser/schemas/components.rb +++ b/lib/openapi_parser/schemas/components.rb @@ -28,5 +28,9 @@ class Components < Base # @!attribute [r] path_items # @return [Hash{String => PathItem}, nil] path item objects (OpenAPI 3.1+) openapi_attr_hash_object :path_items, PathItem, reference: true, schema_key: :pathItems + + # @!attribute [r] media_types + # @return [Hash{String => MediaType}, nil] media type objects (OpenAPI 3.2+) + openapi_attr_hash_object :media_types, MediaType, reference: true, schema_key: :mediaTypes end end diff --git a/lib/openapi_parser/schemas/openapi.rb b/lib/openapi_parser/schemas/openapi.rb index 41cc5454..a99f3bea 100644 --- a/lib/openapi_parser/schemas/openapi.rb +++ b/lib/openapi_parser/schemas/openapi.rb @@ -5,11 +5,14 @@ module OpenAPIParser::Schemas class OpenAPI < Base - def initialize(raw_schema, config, uri: nil, schema_registry: {}) + # @param [OpenAPIParser::Schemas::OpenAPI, nil] referrer the document whose $ref loads this one + def initialize(raw_schema, config, uri: nil, schema_registry: {}, referrer: nil) + # set before super: child objects consult use_3_2_features? while they're built + @config = config + @referrer = referrer super('#', nil, self, raw_schema) @find_object_cache = {} @path_item_finder = OpenAPIParser::PathItemFinder.new(paths) if paths # invalid definition - @config = config @uri = uri @schema_registry = schema_registry @@ -33,6 +36,20 @@ def openapi_version Gem::Version.new(openapi).release end + # Whether OpenAPI 3.2 runtime behavior applies: the document declares 3.2 + # or later, or the allow_3_2_features config is set. A referenced file + # that declares no version follows the document that loaded it. + # @return [Boolean] + def use_3_2_features? + return true if @config.allow_3_2_features + + version = openapi_version + return version >= Gem::Version.new('3.2') if version + return @referrer.use_3_2_features? if openapi.nil? && @referrer + + false + end + # @!attribute [r] paths # @return [Paths, nil] openapi_attr_object :paths, Paths, reference: false @@ -71,7 +88,7 @@ def load_another_schema(uri) loaded = @schema_registry[resolved_uri] return loaded if loaded - OpenAPIParser.load_uri(resolved_uri, config: @config, schema_registry: @schema_registry) + OpenAPIParser.load_uri(resolved_uri, config: @config, schema_registry: @schema_registry, referrer: self) end private diff --git a/lib/openapi_parser/schemas/request_body.rb b/lib/openapi_parser/schemas/request_body.rb index fa674930..62b5ade2 100644 --- a/lib/openapi_parser/schemas/request_body.rb +++ b/lib/openapi_parser/schemas/request_body.rb @@ -12,7 +12,7 @@ class RequestBody < Base # @!attribute [r] content # @return [Hash{String => MediaType}, nil] content type to MediaType object - openapi_attr_hash_object :content, MediaType, reference: false + openapi_attr_hash_object :content, MediaType, reference: OpenAPIParser::MediaTypeSelectable::CONTENT_REFERENCE # @param [String] content_type # @param [Hash] params diff --git a/lib/openapi_parser/schemas/response.rb b/lib/openapi_parser/schemas/response.rb index 9c2094fd..c203c995 100644 --- a/lib/openapi_parser/schemas/response.rb +++ b/lib/openapi_parser/schemas/response.rb @@ -8,7 +8,7 @@ class Response < Base # @!attribute [r] content # @return [Hash{String => MediaType}, nil] content_type to MediaType hash - openapi_attr_hash_object :content, MediaType, reference: false + openapi_attr_hash_object :content, MediaType, reference: OpenAPIParser::MediaTypeSelectable::CONTENT_REFERENCE # @!attribute [r] headers # @return [Hash{String => Header}, nil] header string to Header diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index fffa1c62..7dfe8bb6 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -24,6 +24,7 @@ require_relative 'spec_validator/rules/xml_attribute_deprecation' require_relative 'spec_validator/rules/xml_wrapped_deprecation' require_relative 'spec_validator/rules/security_scheme_fields_before_32' +require_relative 'spec_validator/rules/media_types_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -95,6 +96,7 @@ def rules Rules::XmlAttributeDeprecation, Rules::XmlWrappedDeprecation, Rules::SecuritySchemeFieldsBefore32, + Rules::MediaTypesBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/media_types_before_32.rb b/lib/openapi_parser/spec_validator/rules/media_types_before_32.rb new file mode 100644 index 00000000..39b3d4c2 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/media_types_before_32.rb @@ -0,0 +1,24 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `components.mediaTypes` is a 3.2 addition. The parse layer accepts + # it permissively; this rule reports the version mismatch. + class MediaTypesBefore32 < Rule + def check(root) + return [] unless version_before?('3.2') + + components = root.components + return [] unless components + + raw = components.raw_schema + return [] unless raw.is_a?(Hash) && raw.key?('mediaTypes') + + [violation( + path: "#{components.object_reference}/mediaTypes", + message: '`components.mediaTypes` is a 3.2 addition; earlier documents should not declare it', + )] + end + end + end + end +end diff --git a/sig/openapi_parser.rbs b/sig/openapi_parser.rbs index e93db499..915935cf 100644 --- a/sig/openapi_parser.rbs +++ b/sig/openapi_parser.rbs @@ -2,20 +2,21 @@ module OpenAPIParser def self.parse: (Hash[bot, bot] schema, ?Hash[bot, bot] config) -> OpenAPIParser::Schemas::OpenAPI def self.parse_with_filepath: (Hash[bot, bot] schema, String filepath, ?Hash[bot, bot] config) -> OpenAPIParser::Schemas::OpenAPI def self.load: (String filepath, ?Hash[bot, bot] config) -> OpenAPIParser::Schemas::OpenAPI - def self.load_uri: (OpenAPIParser::readable_uri uri, config: untyped, schema_registry: Hash[bot, bot]) -> OpenAPIParser::Schemas::OpenAPI + def self.load_uri: (OpenAPIParser::readable_uri uri, config: untyped, schema_registry: Hash[bot, bot], ?referrer: OpenAPIParser::Schemas::OpenAPI?) -> OpenAPIParser::Schemas::OpenAPI def self.file_uri: (String filepath) -> URI::Generic def self.parse_file: (String? content, String ext) -> Hash[bot, bot] def self.parse_yaml: (String? content) -> Hash[bot, bot] def self.parse_json: (String? content) -> Hash[bot, bot] - def self.load_hash: (Hash[bot, bot] hash, config: untyped, uri: OpenAPIParser::readable_uri?, schema_registry: Hash[bot, bot]) -> OpenAPIParser::Schemas::OpenAPI + def self.load_hash: (Hash[bot, bot] hash, config: untyped, uri: OpenAPIParser::readable_uri?, schema_registry: Hash[bot, bot], ?referrer: OpenAPIParser::Schemas::OpenAPI?) -> OpenAPIParser::Schemas::OpenAPI end module OpenAPIParser module Schemas class OpenAPI - def initialize: (Hash[bot, bot] hash, untyped config, uri: OpenAPIParser::readable_uri?, schema_registry: Hash[bot, bot]) -> OpenAPIParser::Schemas::OpenAPI + def initialize: (Hash[bot, bot] hash, untyped config, uri: OpenAPIParser::readable_uri?, schema_registry: Hash[bot, bot], ?referrer: OpenAPIParser::Schemas::OpenAPI?) -> OpenAPIParser::Schemas::OpenAPI def openapi: () -> String? def openapi_version: () -> Gem::Version? + def use_3_2_features?: () -> bool end end end diff --git a/sig/openapi_parser/config.rbs b/sig/openapi_parser/config.rbs index e4a65cff..1a6b2c1c 100644 --- a/sig/openapi_parser/config.rbs +++ b/sig/openapi_parser/config.rbs @@ -9,6 +9,7 @@ module OpenAPIParser def initialize: (untyped config) -> untyped def allow_empty_date_and_datetime: -> bool + def allow_3_2_features: -> bool def date_coerce_class: -> (singleton(Object) | nil) def datetime_coerce_class: -> (singleton(Object) | nil) def coerce_value: -> bool diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 52316b6f..6dcb3aa6 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -140,6 +140,10 @@ module OpenAPIParser NEW_FIELDS: Array[String] def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class MediaTypesBefore32 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/media_type_refs_external_30.yaml b/spec/data/openapi_3_2/media_type_refs_external_30.yaml new file mode 100644 index 00000000..49577237 --- /dev/null +++ b/spec/data/openapi_3_2/media_type_refs_external_30.yaml @@ -0,0 +1,8 @@ +openapi: 3.0.3 +info: + title: Pet API + version: '1.0' +paths: + # the referenced file declares no version, so it follows this document + /pets: + $ref: 'media_type_refs_fragment.yaml#/paths/~1pets' diff --git a/spec/data/openapi_3_2/media_type_refs_external_32.yaml b/spec/data/openapi_3_2/media_type_refs_external_32.yaml new file mode 100644 index 00000000..0cb08ffa --- /dev/null +++ b/spec/data/openapi_3_2/media_type_refs_external_32.yaml @@ -0,0 +1,8 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + # the referenced file declares no version, so it follows this document + /pets: + $ref: 'media_type_refs_fragment.yaml#/paths/~1pets' diff --git a/spec/data/openapi_3_2/media_type_refs_fragment.yaml b/spec/data/openapi_3_2/media_type_refs_fragment.yaml new file mode 100644 index 00000000..aa3d9dd0 --- /dev/null +++ b/spec/data/openapi_3_2/media_type_refs_fragment.yaml @@ -0,0 +1,19 @@ +# no `openapi` field: a fragment loaded through a $ref +paths: + /pets: + post: + requestBody: + content: + application/json: + $ref: '#/components/mediaTypes/PetJson' + responses: + '201': + description: Created +components: + mediaTypes: + PetJson: + schema: + type: object + properties: + id: + type: integer diff --git a/spec/data/openapi_3_2/media_types_31.yaml b/spec/data/openapi_3_2/media_types_31.yaml new file mode 100644 index 00000000..85ee4a54 --- /dev/null +++ b/spec/data/openapi_3_2/media_types_31.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK +components: + # `components.mediaTypes` is a 3.2 addition; a 3.1 document has no such + # section, so its use here is a spec violation. + mediaTypes: + PetJson: + schema: + type: object + properties: + id: + type: integer diff --git a/spec/data/openapi_3_2/media_types_32.yaml b/spec/data/openapi_3_2/media_types_32.yaml new file mode 100644 index 00000000..4f4fefab --- /dev/null +++ b/spec/data/openapi_3_2/media_types_32.yaml @@ -0,0 +1,33 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + get: + summary: List pets + responses: + '200': + description: OK + content: + application/json: + $ref: '#/components/mediaTypes/PetJson' + post: + summary: Create a pet + requestBody: + content: + application/json: + $ref: '#/components/mediaTypes/PetJson' + responses: + '201': + description: Created +components: + # `components.mediaTypes` is legitimate under 3.2, so no violation is + # expected here. + mediaTypes: + PetJson: + schema: + type: object + properties: + id: + type: integer diff --git a/spec/openapi_parser/concerns/expandable_spec.rb b/spec/openapi_parser/concerns/expandable_spec.rb index 24ec03f7..ab0eabd4 100644 --- a/spec/openapi_parser/concerns/expandable_spec.rb +++ b/spec/openapi_parser/concerns/expandable_spec.rb @@ -54,5 +54,103 @@ expect(root.request_operation(:get, '/books').class).to eq OpenAPIParser::RequestOperation end end + + context 'content that $refs into components.mediaTypes (OpenAPI 3.2)' do + let(:root) do + OpenAPIParser.parse( + load_yaml_file('./spec/data/openapi_3_2/media_types_32.yaml'), + strict_reference_validation: true, + ) + end + + it 'resolves the media type in a request body' do + media_type = root.request_operation(:post, '/pets').operation_object.request_body.content['application/json'] + expect(media_type.schema.class).to eq OpenAPIParser::Schemas::Schema + end + + it 'resolves the media type in a response' do + media_type = root.request_operation(:get, '/pets').operation_object.responses.response['200'].content['application/json'] + expect(media_type.schema.class).to eq OpenAPIParser::Schemas::Schema + end + + it 'validates a request body against the referenced media type schema' do + request_operation = root.request_operation(:post, '/pets') + expect { request_operation.validate_request_body('application/json', { 'id' => 'not-an-integer' }) } + .to raise_error(OpenAPIParser::ValidateError) + end + end + + context 'content $refs outside components.mediaTypes' do + # POST /pets reuses the media type declared on POST /templates + def parse_content_ref(version, ref, config = {}) + operation = ->(media_type) { { 'requestBody' => { 'content' => { 'application/json' => media_type } }, 'responses' => { '201' => { 'description' => 'Created' } } } } + raw = { + 'openapi' => version, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => { + '/templates' => { 'post' => operation.({ 'schema' => { 'type' => 'integer' } }) }, + '/pets' => { 'post' => operation.({ '$ref' => ref }) }, + }, + } + OpenAPIParser.parse(raw, { strict_reference_validation: false }.merge(config)) + end + + let(:template_ref) { '#/paths/~1templates/post/requestBody/content/application~1json' } + + %w[3.0.3 3.1.0].each do |version| + context "in a #{version} document" do + it 'leaves the $ref unresolved and accepts any body, as before 3.2' do + request_operation = parse_content_ref(version, template_ref).request_operation(:post, '/pets') + expect(request_operation.operation_object.request_body.content['application/json'].schema).to eq nil + expect(request_operation.validate_request_body('application/json', { 'id' => 'x' })).to eq({ 'id' => 'x' }) + end + + it 'does not raise for a missing target under strict_reference_validation' do + expect { parse_content_ref(version, '#/nope', { strict_reference_validation: true }) }.not_to raise_error + end + + it 'resolves the $ref with allow_3_2_features' do + request_operation = parse_content_ref(version, template_ref, { allow_3_2_features: true }).request_operation(:post, '/pets') + expect { request_operation.validate_request_body('application/json', { 'id' => 'x' }) } + .to raise_error(OpenAPIParser::ValidateError) + end + end + end + + context 'in a 3.2 document' do + it 'resolves the $ref' do + request_operation = parse_content_ref('3.2.0', template_ref).request_operation(:post, '/pets') + expect { request_operation.validate_request_body('application/json', { 'id' => 'x' }) } + .to raise_error(OpenAPIParser::ValidateError) + end + + it 'raises for a missing target under strict_reference_validation' do + expect { parse_content_ref('3.2.0', '#/nope', { strict_reference_validation: true }) } + .to raise_error(OpenAPIParser::MissingReferenceError) + end + + it 'falls back to a schemaless media type for a missing target without strict_reference_validation' do + request_operation = parse_content_ref('3.2.0', '#/nope').request_operation(:post, '/pets') + expect(request_operation.operation_object.request_body.content['application/json'].class).to eq OpenAPIParser::Schemas::MediaType + expect(request_operation.validate_request_body('application/json', { 'id' => 'x' })).to eq({ 'id' => 'x' }) + end + end + end + + context 'content $refs in a referenced file that declares no version' do + def load_pets_operation(suffix) + OpenAPIParser.load("./spec/data/openapi_3_2/media_type_refs_external_#{suffix}.yaml", strict_reference_validation: false) + .request_operation(:post, '/pets') + end + + it 'resolves them when the loading document is 3.2' do + expect { load_pets_operation('32').validate_request_body('application/json', { 'id' => 'x' }) } + .to raise_error(OpenAPIParser::ValidateError) + end + + it 'leaves them unresolved when the loading document is 3.0' do + expect(load_pets_operation('30').validate_request_body('application/json', { 'id' => 'x' })).to eq({ 'id' => 'x' }) + end + end end end diff --git a/spec/openapi_parser/schemas/components_spec.rb b/spec/openapi_parser/schemas/components_spec.rb index f6c8147c..907e7214 100644 --- a/spec/openapi_parser/schemas/components_spec.rb +++ b/spec/openapi_parser/schemas/components_spec.rb @@ -25,6 +25,17 @@ end end + describe 'mediaTypes (OpenAPI 3.2)' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/media_types_32.yaml'), {}) } + subject { root.find_object('#/components') } + + it 'is parsed as a MediaType with its schema' do + media_type = subject.media_types['PetJson'] + expect(media_type.class).to eq OpenAPIParser::Schemas::MediaType + expect(media_type.schema.class).to eq OpenAPIParser::Schemas::Schema + end + end + describe 'pathItems (OpenAPI 3.1)' do let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_1/components_path_items.yaml'), {}) } subject { root.find_object('#/components') } diff --git a/spec/openapi_parser/schemas/open_api_spec.rb b/spec/openapi_parser/schemas/open_api_spec.rb index 4360e44a..486d7d48 100644 --- a/spec/openapi_parser/schemas/open_api_spec.rb +++ b/spec/openapi_parser/schemas/open_api_spec.rb @@ -89,4 +89,30 @@ def parse_with_openapi_field(value, present: true) end end end + + describe '#use_3_2_features?' do + def parse_with_openapi_field(value, config = {}, present: true) + schema = { 'info' => { 'title' => 'test', 'version' => '1.0' }, 'paths' => {} } + schema['openapi'] = value if present + OpenAPIParser.parse(schema, { strict_reference_validation: false }.merge(config)) + end + + it 'is true for a 3.2 document' do + expect(parse_with_openapi_field('3.2.0').use_3_2_features?).to eq true + end + + it 'is false for 3.0 and 3.1 documents' do + expect(parse_with_openapi_field('3.0.3').use_3_2_features?).to eq false + expect(parse_with_openapi_field('3.1.0').use_3_2_features?).to eq false + end + + it 'is false when the version is missing or malformed' do + expect(parse_with_openapi_field(nil, present: false).use_3_2_features?).to eq false + expect(parse_with_openapi_field('not-a-version').use_3_2_features?).to eq false + end + + it 'is true for any document with allow_3_2_features' do + expect(parse_with_openapi_field('3.0.3', { allow_3_2_features: true }).use_3_2_features?).to eq true + end + end end diff --git a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb index 5ae7fc60..7cea4038 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -136,4 +136,18 @@ def expect_clean(file) expect_clean('security_scheme_fields_32.yaml') end end + + describe 'components.mediaTypes (3.2 addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('media_types_31.yaml', [:media_types_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('media_types_31.yaml', [:media_types_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('media_types_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/media_types_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/media_types_before_32_spec.rb new file mode 100644 index 00000000..f7f8d837 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/media_types_before_32_spec.rb @@ -0,0 +1,79 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::MediaTypesBefore32' do + def base_doc(openapi_version_string) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + } + end + + def doc_with_media_types(openapi_version_string) + raw = base_doc(openapi_version_string) + raw['components'] = { 'mediaTypes' => { 'PetJson' => { 'schema' => { 'type' => 'object' } } } } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_media_types(openapi_version_string) + raw = base_doc(openapi_version_string) + raw['components'] = { 'schemas' => { 'Sample' => { 'type' => 'string' } } } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::MediaTypesBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using components.mediaTypes' do + it 'reports no violation' do + root = doc_with_media_types('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without components.mediaTypes' do + it 'reports no violation' do + root = doc_without_media_types('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using components.mediaTypes' do + it 'reports one violation pointing at the section' do + root = doc_with_media_types('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/components/mediaTypes' + expect(violations.first.rule_name).to eq :media_types_before32 + end + end + + context 'with a 3.1 document without components.mediaTypes' do + it 'reports no violation' do + root = doc_without_media_types('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using components.mediaTypes' do + it 'reports one violation' do + root = doc_with_media_types('3.0.0') + expect(run_rule_for(root).size).to eq 1 + end + end + + context 'with a document that has no components section' do + it 'reports no violation' do + root = OpenAPIParser.parse(base_doc('3.1.0'), strict_reference_validation: false) + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_media_types('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end