From b7fd20a9f5e3d6514989f46fc8ad05eebcc3c9b4 Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:43:43 -0700 Subject: [PATCH] Support Media Type itemSchema for OpenAPI 3.2 (parse + StreamingFieldsBefore32 rule) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + lib/openapi_parser/schemas/media_type.rb | 4 + lib/openapi_parser/spec_validator.rb | 2 + .../rules/streaming_fields_before_32.rb | 56 +++++++++ sig/openapi_parser/spec_validator.rbs | 7 ++ .../data/openapi_3_2/streaming_fields_31.yaml | 27 +++++ .../data/openapi_3_2/streaming_fields_32.yaml | 26 ++++ .../openapi_parser/schemas/media_type_spec.rb | 10 ++ .../spec_validator/integration_3_2_spec.rb | 14 +++ .../rules/streaming_fields_before_32_spec.rb | 111 ++++++++++++++++++ 10 files changed, 259 insertions(+) create mode 100644 lib/openapi_parser/spec_validator/rules/streaming_fields_before_32.rb create mode 100644 spec/data/openapi_3_2/streaming_fields_31.yaml create mode 100644 spec/data/openapi_3_2/streaming_fields_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/streaming_fields_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 018f025e..9f42f3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ * `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) + * `StreamingFieldsBefore32`: detect Media Type `itemSchema` / `itemEncoding` / `prefixEncoding` and nested Encoding Object `encoding` / `itemEncoding` / `prefixEncoding` usage in pre-3.2 documents (3.2 additions) * 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 +* support `itemSchema` (OpenAPI 3.2) in the parse layer; it is not yet used to validate sequential media type bodies ## 2.5.0 (2026-10-21) * add OpenAPI 3.2 `SpecValidator` rules diff --git a/lib/openapi_parser/schemas/media_type.rb b/lib/openapi_parser/schemas/media_type.rb index 88bb6665..a0af1737 100644 --- a/lib/openapi_parser/schemas/media_type.rb +++ b/lib/openapi_parser/schemas/media_type.rb @@ -8,6 +8,10 @@ class MediaType < Base # @return [Schema, nil] OpenAPI3 Schema object openapi_attr_object :schema, Schema, reference: true + # @!attribute [r] item_schema + # @return [Schema, nil] schema for each item of a sequential media type (OpenAPI 3.2+); not yet used for validation + openapi_attr_object :item_schema, Schema, reference: true, schema_key: :itemSchema + # validate params by schema definitions # @param [Hash] params # @param [OpenAPIParser::SchemaValidator::Options] options diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 539bddde..cab470e6 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -26,6 +26,7 @@ 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' +require_relative 'spec_validator/rules/streaming_fields_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -99,6 +100,7 @@ def rules Rules::XmlWrappedDeprecation, Rules::SecuritySchemeFieldsBefore32, Rules::MediaTypesBefore32, + Rules::StreamingFieldsBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/streaming_fields_before_32.rb b/lib/openapi_parser/spec_validator/rules/streaming_fields_before_32.rb new file mode 100644 index 00000000..7a028b34 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/streaming_fields_before_32.rb @@ -0,0 +1,56 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 adds `itemSchema`, `itemEncoding`, and `prefixEncoding` to the + # Media Type Object for sequential media types (SSE, JSON Lines, + # JSON Sequences), and lets an Encoding Object nest `encoding`, + # `itemEncoding`, and `prefixEncoding`. The parse layer accepts + # `itemSchema` permissively; this rule reports the version mismatch. + # Media Types under a Parameter or Header `content` are not modeled, + # so fields there are not reached. + class StreamingFieldsBefore32 < Rule + NEW_FIELDS = %w[itemSchema itemEncoding prefixEncoding].freeze + NEW_ENCODING_FIELDS = %w[encoding itemEncoding prefixEncoding].freeze + + def check(root) + return [] unless version_before?('3.2') + + violations = [] + each_node(root, OpenAPIParser::Schemas::MediaType) do |node| + raw = node.raw_schema + next unless raw.is_a?(Hash) + + NEW_FIELDS.each do |field| + next unless raw.key?(field) + + violations << violation( + path: "#{node.object_reference}/#{field}", + message: "`#{field}` on a Media Type Object is a 3.2 addition; earlier documents have no such field", + ) + end + + violations.concat(encoding_violations(node, raw['encoding'])) + end + violations + end + + private + + def encoding_violations(node, encodings) + return [] unless encodings.is_a?(Hash) + + encodings.flat_map do |property, encoding| + next [] unless encoding.is_a?(Hash) + + NEW_ENCODING_FIELDS.select { |field| encoding.key?(field) }.map do |field| + violation( + path: "#{node.object_reference}/encoding/#{escape_reference(property)}/#{field}", + message: "`#{field}` on an Encoding Object is a 3.2 addition; earlier documents have no such field", + ) + end + end + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index a7875963..dc82ae02 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -148,6 +148,13 @@ module OpenAPIParser class MediaTypesBefore32 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class StreamingFieldsBefore32 < Rule + NEW_FIELDS: Array[String] + NEW_ENCODING_FIELDS: Array[String] + private def encoding_violations: (untyped node, untyped encodings) -> Array[SpecValidator::SpecViolation] + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/streaming_fields_31.yaml b/spec/data/openapi_3_2/streaming_fields_31.yaml new file mode 100644 index 00000000..7bd4b3a5 --- /dev/null +++ b/spec/data/openapi_3_2/streaming_fields_31.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Event API + version: '1.0' +paths: + /events: + get: + summary: Stream events + responses: + '200': + description: OK + content: + text/event-stream: + # `itemSchema`, `itemEncoding`, and `prefixEncoding` on a + # Media Type Object are 3.2 additions for sequential media + # types; a 3.1 document has no such fields, so their use here + # is a spec violation. + itemSchema: + type: object + properties: + event: + type: string + itemEncoding: + event: + contentType: text/plain + prefixEncoding: + - contentType: application/json diff --git a/spec/data/openapi_3_2/streaming_fields_32.yaml b/spec/data/openapi_3_2/streaming_fields_32.yaml new file mode 100644 index 00000000..f3166916 --- /dev/null +++ b/spec/data/openapi_3_2/streaming_fields_32.yaml @@ -0,0 +1,26 @@ +openapi: 3.2.0 +info: + title: Event API + version: '1.0' +paths: + /events: + get: + summary: Stream events + responses: + '200': + description: OK + content: + text/event-stream: + # `itemSchema`, `itemEncoding`, and `prefixEncoding` are + # legitimate Media Type Object fields under 3.2, so no + # violation is expected here. + itemSchema: + type: object + properties: + event: + type: string + itemEncoding: + event: + contentType: text/plain + prefixEncoding: + - contentType: application/json diff --git a/spec/openapi_parser/schemas/media_type_spec.rb b/spec/openapi_parser/schemas/media_type_spec.rb index 086ae80f..6e4fecb4 100644 --- a/spec/openapi_parser/schemas/media_type_spec.rb +++ b/spec/openapi_parser/schemas/media_type_spec.rb @@ -19,4 +19,14 @@ expect(subject.schema.class).to eq OpenAPIParser::Schemas::Reference end end + + describe 'itemSchema (OpenAPI 3.2)' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/streaming_fields_32.yaml'), {}) } + + it 'is parsed as a Schema' do + media_type = root.find_object('#/paths/~1events/get/responses/200/content/text~1event-stream') + expect(media_type.item_schema.class).to eq OpenAPIParser::Schemas::Schema + expect(media_type.item_schema.type).to eq 'object' + 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 e83e458d..00747ffe 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -150,4 +150,18 @@ def expect_clean(file) expect_clean('media_types_32.yaml') end end + + describe 'Media Type itemSchema/itemEncoding/prefixEncoding (3.2 additions)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('streaming_fields_31.yaml', [:streaming_fields_before32, :streaming_fields_before32, :streaming_fields_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('streaming_fields_31.yaml', [:streaming_fields_before32, :streaming_fields_before32, :streaming_fields_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('streaming_fields_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/streaming_fields_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/streaming_fields_before_32_spec.rb new file mode 100644 index 00000000..f2be365d --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/streaming_fields_before_32_spec.rb @@ -0,0 +1,111 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::StreamingFieldsBefore32' do + def base_doc(openapi_version_string, media_type) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => { + '/events' => { + 'get' => { + 'responses' => { + '200' => { + 'description' => 'OK', + 'content' => { 'text/event-stream' => media_type }, + }, + }, + }, + }, + }, + } + end + + def doc_with_streaming_fields(openapi_version_string) + media_type = { + 'itemSchema' => { 'type' => 'object' }, + 'itemEncoding' => { 'event' => { 'contentType' => 'text/plain' } }, + 'prefixEncoding' => [{ 'contentType' => 'application/json' }], + } + OpenAPIParser.parse(base_doc(openapi_version_string, media_type), strict_reference_validation: false) + end + + def doc_without_streaming_fields(openapi_version_string) + media_type = { 'schema' => { 'type' => 'string' } } + OpenAPIParser.parse(base_doc(openapi_version_string, media_type), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::StreamingFieldsBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using the streaming fields' do + it 'reports no violation' do + root = doc_with_streaming_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without the streaming fields' do + it 'reports no violation' do + root = doc_without_streaming_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using the streaming fields' do + it 'reports one violation per offending field pointing at the media type' do + root = doc_with_streaming_fields('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 3 + base = '#/paths/~1events/get/responses/200/content/text~1event-stream' + expect(violations.map(&:path)).to match_array [ + "#{base}/itemSchema", + "#{base}/itemEncoding", + "#{base}/prefixEncoding", + ] + expect(violations.first.rule_name).to eq :streaming_fields_before32 + end + end + + context 'with a 3.1 document without the streaming fields' do + it 'reports no violation' do + root = doc_without_streaming_fields('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using the streaming fields' do + it 'reports violations' do + root = doc_with_streaming_fields('3.0.0') + expect(run_rule_for(root).size).to eq 3 + end + end + + context 'with a 3.1 document nesting encodings inside an Encoding Object' do + it 'reports each nested field' do + media_type = { + 'schema' => { 'type' => 'object' }, + 'encoding' => { + 'parts' => { + 'contentType' => 'multipart/mixed', + 'itemEncoding' => { 'contentType' => 'application/json' }, + 'prefixEncoding' => [{ 'contentType' => 'text/plain' }], + 'encoding' => { 'meta' => { 'contentType' => 'application/json' } }, + }, + 'plain' => { 'contentType' => 'text/plain' }, + }, + } + root = OpenAPIParser.parse(base_doc('3.1.0', media_type), strict_reference_validation: false) + expect(run_rule_for(root).map(&:path)).to match_array %w[itemEncoding prefixEncoding encoding].map { |field| + "#/paths/~1events/get/responses/200/content/text~1event-stream/encoding/parts/#{field}" + } + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_streaming_fields('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end