From ea27bdebfaf3ee83a2fa3bc51eaf298979da5fd0 Mon Sep 17 00:00:00 2001 From: jonathan schatz Date: Thu, 17 Sep 2026 18:48:32 -0700 Subject: [PATCH] Support query method and additionalOperations for OpenAPI 3.2 (parse + operation lookup + rules) PathItem parses the 3.2 query operation and additionalOperations map. In 3.2 documents (or with allow_3_2_features), operation()/request_operation() resolve them and match methods case-insensitively; earlier documents keep the exact-case standard-method lookup. QueryMethodBefore32 / AdditionalOperationsBefore32 flag their use in pre-3.2 documents. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + lib/openapi_parser/schemas/path_item.rb | 38 ++++++++-- lib/openapi_parser/spec_validator.rb | 4 + .../rules/additional_operations_before_32.rb | 26 +++++++ .../rules/query_method_before_32.rb | 26 +++++++ sig/openapi_parser/spec_validator.rbs | 8 ++ .../openapi_3_2/additional_operations_31.yaml | 14 ++++ .../openapi_3_2/additional_operations_32.yaml | 14 ++++ spec/data/openapi_3_2/query_method_31.yaml | 21 ++++++ spec/data/openapi_3_2/query_method_32.yaml | 21 ++++++ spec/openapi_parser/request_operation_spec.rb | 62 ++++++++++++++++ spec/openapi_parser/schemas/path_item_spec.rb | 27 +++++++ .../spec_validator/integration_3_2_spec.rb | 28 +++++++ .../additional_operations_before_32_spec.rb | 74 +++++++++++++++++++ .../rules/query_method_before_32_spec.rb | 70 ++++++++++++++++++ 15 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 lib/openapi_parser/spec_validator/rules/additional_operations_before_32.rb create mode 100644 lib/openapi_parser/spec_validator/rules/query_method_before_32.rb create mode 100644 spec/data/openapi_3_2/additional_operations_31.yaml create mode 100644 spec/data/openapi_3_2/additional_operations_32.yaml create mode 100644 spec/data/openapi_3_2/query_method_31.yaml create mode 100644 spec/data/openapi_3_2/query_method_32.yaml create mode 100644 spec/openapi_parser/spec_validator/rules/additional_operations_before_32_spec.rb create mode 100644 spec/openapi_parser/spec_validator/rules/query_method_before_32_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 861d4cf4..a125ae21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ * `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) * `DefaultMappingBefore32`: detect Discriminator `defaultMapping` usage in pre-3.2 documents (3.2 addition) + * `QueryMethodBefore32`: detect Path Item `query` usage in pre-3.2 documents (3.2 addition) + * `AdditionalOperationsBefore32`: detect Path Item `additionalOperations` 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 @@ -42,6 +44,8 @@ * 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 * support Discriminator `defaultMapping` (OpenAPI 3.2): fallback schema when the discriminator property is absent or its value has no explicit or implicit mapping; honored in 3.2 documents +* support the `query` HTTP method and `additionalOperations` (OpenAPI 3.2) on Path Items, including request-operation lookup, in 3.2 documents +* match HTTP methods case-insensitively in request-operation lookup in 3.2 documents (`request_operation("GET", path)` still returns nil before 3.2) ## 2.3.1 (2025-11-14) * add optional date coercion with behavior matching existing datetime coercion diff --git a/lib/openapi_parser/schemas/path_item.rb b/lib/openapi_parser/schemas/path_item.rb index 3cb9c71d..d424cd20 100644 --- a/lib/openapi_parser/schemas/path_item.rb +++ b/lib/openapi_parser/schemas/path_item.rb @@ -3,20 +3,46 @@ module OpenAPIParser::Schemas class PathItem < Base + # `query` is an OpenAPI 3.2 addition + STANDARD_METHODS = %w[get put post delete options head patch trace query].freeze + PRE_3_2_METHODS = (STANDARD_METHODS - %w[query]).freeze + openapi_attr_values :summary, :description - openapi_attr_objects :get, :put, :post, :delete, :options, :head, :patch, :trace, Operation + openapi_attr_objects :get, :put, :post, :delete, :options, :head, :patch, :trace, :query, Operation openapi_attr_list_object :parameters, Parameter, reference: true - # @return [Operation] + # @!attribute [r] additional_operations + # @return [Hash{String => Operation}, nil] operations for non-standard HTTP methods (OpenAPI 3.2+) + openapi_attr_hash_object :additional_operations, Operation, reference: false, schema_key: :additionalOperations + + # @return [Operation, nil] def operation(method) - public_send(method) - rescue NoMethodError - nil + # before 3.2: exact-case standard methods only, as it always was + unless root.use_3_2_features? + return PRE_3_2_METHODS.include?(method.to_s) ? public_send(method.to_s) : nil + end + + method_name = method.to_s.downcase + return public_send(method_name) if STANDARD_METHODS.include?(method_name) + + additional_operation(method.to_s) end def set_path_item_to_operation - [:get, :put, :post, :delete, :options, :head, :patch, :trace].each{ |method| operation(method)&.set_parent_path_item(self)} + STANDARD_METHODS.each { |method| operation(method)&.set_parent_path_item(self) } + additional_operations&.each_value { |op| op.set_parent_path_item(self) } end + + private + + # additionalOperations keys are method names as sent on the wire + # (conventionally uppercase); prefer an exact match, else ignore case + def additional_operation(name) + operations = additional_operations + return nil unless operations + + operations.fetch(name) { operations.find { |key, _| key.casecmp?(name) }&.last } + end end end diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index fc2ec173..6326049f 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -27,6 +27,8 @@ require_relative 'spec_validator/rules/media_types_before_32' require_relative 'spec_validator/rules/streaming_fields_before_32' require_relative 'spec_validator/rules/default_mapping_before_32' +require_relative 'spec_validator/rules/query_method_before_32' +require_relative 'spec_validator/rules/additional_operations_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -101,6 +103,8 @@ def rules Rules::MediaTypesBefore32, Rules::StreamingFieldsBefore32, Rules::DefaultMappingBefore32, + Rules::QueryMethodBefore32, + Rules::AdditionalOperationsBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rules/additional_operations_before_32.rb b/lib/openapi_parser/spec_validator/rules/additional_operations_before_32.rb new file mode 100644 index 00000000..4739af58 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/additional_operations_before_32.rb @@ -0,0 +1,26 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `additionalOperations` is a 3.2 Path Item addition for non-standard + # HTTP methods. The parse layer accepts it permissively; this rule + # reports the version mismatch. + class AdditionalOperationsBefore32 < Rule + def check(root) + return [] unless version_before?('3.2') + + violations = [] + each_node(root, OpenAPIParser::Schemas::PathItem) do |node| + raw = node.raw_schema + next unless raw.is_a?(Hash) && raw.key?('additionalOperations') + + violations << violation( + path: "#{node.object_reference}/additionalOperations", + message: '`additionalOperations` is a 3.2 Path Item addition; earlier documents have no such field', + ) + end + violations + end + end + end + end +end diff --git a/lib/openapi_parser/spec_validator/rules/query_method_before_32.rb b/lib/openapi_parser/spec_validator/rules/query_method_before_32.rb new file mode 100644 index 00000000..61e2694f --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/query_method_before_32.rb @@ -0,0 +1,26 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `query` is a 3.2 Path Item addition for the HTTP QUERY method. The + # parse layer accepts it permissively; this rule reports the version + # mismatch. + class QueryMethodBefore32 < Rule + def check(root) + return [] unless version_before?('3.2') + + violations = [] + each_node(root, OpenAPIParser::Schemas::PathItem) do |node| + raw = node.raw_schema + next unless raw.is_a?(Hash) && raw.key?('query') + + violations << violation( + path: "#{node.object_reference}/query", + message: 'the `query` operation is a 3.2 Path Item addition; earlier documents have no such field', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index e9651d3d..d18c9c1c 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -155,6 +155,14 @@ module OpenAPIParser class DefaultMappingBefore32 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class QueryMethodBefore32 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end + + class AdditionalOperationsBefore32 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/additional_operations_31.yaml b/spec/data/openapi_3_2/additional_operations_31.yaml new file mode 100644 index 00000000..91f120ff --- /dev/null +++ b/spec/data/openapi_3_2/additional_operations_31.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + # `additionalOperations` is a 3.2 Path Item addition; a 3.1 document + # has no such field, so its use here is a spec violation. + additionalOperations: + COPY: + summary: Copy pets + responses: + '200': + description: OK diff --git a/spec/data/openapi_3_2/additional_operations_32.yaml b/spec/data/openapi_3_2/additional_operations_32.yaml new file mode 100644 index 00000000..097c2f35 --- /dev/null +++ b/spec/data/openapi_3_2/additional_operations_32.yaml @@ -0,0 +1,14 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + # `additionalOperations` is legitimate under 3.2, so no violation is + # expected here. + additionalOperations: + COPY: + summary: Copy pets + responses: + '200': + description: OK diff --git a/spec/data/openapi_3_2/query_method_31.yaml b/spec/data/openapi_3_2/query_method_31.yaml new file mode 100644 index 00000000..771e3b30 --- /dev/null +++ b/spec/data/openapi_3_2/query_method_31.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + # the `query` operation is a 3.2 Path Item addition; a 3.1 document has + # no such field, so its use here is a spec violation. + query: + summary: Query pets + requestBody: + content: + application/json: + schema: + type: object + properties: + nameStartsWith: + type: string + responses: + '200': + description: OK diff --git a/spec/data/openapi_3_2/query_method_32.yaml b/spec/data/openapi_3_2/query_method_32.yaml new file mode 100644 index 00000000..ec4c555e --- /dev/null +++ b/spec/data/openapi_3_2/query_method_32.yaml @@ -0,0 +1,21 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + # the `query` operation is legitimate under 3.2, so no violation is + # expected here. + query: + summary: Query pets + requestBody: + content: + application/json: + schema: + type: object + properties: + nameStartsWith: + type: string + responses: + '200': + description: OK diff --git a/spec/openapi_parser/request_operation_spec.rb b/spec/openapi_parser/request_operation_spec.rb index 28ca6a23..7e65334e 100644 --- a/spec/openapi_parser/request_operation_spec.rb +++ b/spec/openapi_parser/request_operation_spec.rb @@ -236,4 +236,66 @@ end end end + + describe 'standard method case' do + it 'does not find an uppercase method name in a 3.0 document, as before 3.2' do + root = OpenAPIParser.parse(petstore_schema, {}) + expect(root.request_operation('GET', '/pets')).to eq nil + end + + it 'finds an uppercase method name in a 3.0 document with allow_3_2_features' do + root = OpenAPIParser.parse(petstore_schema, { allow_3_2_features: true }) + expect(root.request_operation('GET', '/pets').operation_object).to eq root.request_operation(:get, '/pets').operation_object + end + + it 'finds an uppercase method name in a 3.2 document' do + root = OpenAPIParser.parse(petstore_schema.merge('openapi' => '3.2.0'), {}) + expect(root.request_operation('GET', '/pets').operation_object).to eq root.request_operation(:get, '/pets').operation_object + end + end + + describe 'OpenAPI 3.2 operations' do + context 'with a query operation' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/query_method_32.yaml'), {}) } + + it 'finds the request operation and validates its body' do + request_operation = root.request_operation(:query, '/pets') + expect(request_operation.operation_object.class).to eq OpenAPIParser::Schemas::Operation + expect(request_operation.validate_request_body('application/json', { 'nameStartsWith' => 'R' })).to eq({ 'nameStartsWith' => 'R' }) + end + end + + context 'with an additionalOperations method' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/additional_operations_32.yaml'), {}) } + + it 'finds the request operation by its custom method name' do + request_operation = root.request_operation('COPY', '/pets') + expect(request_operation.operation_object.class).to eq OpenAPIParser::Schemas::Operation + expect(request_operation.http_method).to eq 'COPY' + end + + it 'returns nil for an undeclared method' do + expect(root.request_operation('LINK', '/pets')).to eq nil + end + end + end + + describe 'OpenAPI 3.2 operations in a 3.1 document' do + def parse_fixture(name, config = {}) + OpenAPIParser.parse(load_yaml_file("./spec/data/openapi_3_2/#{name}_31.yaml"), config) + end + + it 'does not find a query operation, as before 3.2' do + expect(parse_fixture('query_method').request_operation(:query, '/pets')).to eq nil + end + + it 'does not find an additionalOperations method, as before 3.2' do + expect(parse_fixture('additional_operations').request_operation('COPY', '/pets')).to eq nil + end + + it 'finds both with allow_3_2_features' do + expect(parse_fixture('query_method', { allow_3_2_features: true }).request_operation(:query, '/pets')).not_to eq nil + expect(parse_fixture('additional_operations', { allow_3_2_features: true }).request_operation('COPY', '/pets')).not_to eq nil + end + end end diff --git a/spec/openapi_parser/schemas/path_item_spec.rb b/spec/openapi_parser/schemas/path_item_spec.rb index 6567dc31..50f22349 100644 --- a/spec/openapi_parser/schemas/path_item_spec.rb +++ b/spec/openapi_parser/schemas/path_item_spec.rb @@ -66,4 +66,31 @@ expect(subject).to eq nil # head is null end end + + describe 'query and additionalOperations (OpenAPI 3.2)' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/query_method_32.yaml'), {}) } + let(:path_item) { root.paths.path['/pets'] } + + it 'parses the query operation and finds it via #operation' do + expect(path_item.query.class).to eq OpenAPIParser::Schemas::Operation + expect(path_item.operation(:query)).to eq path_item.query + expect(path_item.operation('QUERY')).to eq path_item.query + end + + describe 'additionalOperations' do + let(:root) { OpenAPIParser.parse(load_yaml_file('./spec/data/openapi_3_2/additional_operations_32.yaml'), {}) } + + it 'parses entries as Operations and finds them via #operation' do + copy = path_item.operation('COPY') + expect(copy.class).to eq OpenAPIParser::Schemas::Operation + expect(path_item.operation(:copy)).to eq copy + expect(path_item.operation('Copy')).to eq copy + expect(path_item.operation('LINK')).to eq nil + end + end + + it 'returns nil for non-operation path item fields' do + expect(path_item.operation(:summary)).to eq nil + 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 62ba80c9..4fc16fb1 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -178,4 +178,32 @@ def expect_clean(file) expect_clean('default_mapping_32.yaml') end end + + describe 'query operation (3.2 Path Item addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('query_method_31.yaml', [:query_method_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('query_method_31.yaml', [:query_method_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('query_method_32.yaml') + end + end + + describe 'additionalOperations (3.2 Path Item addition)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('additional_operations_31.yaml', [:additional_operations_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('additional_operations_31.yaml', [:additional_operations_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('additional_operations_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/additional_operations_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/additional_operations_before_32_spec.rb new file mode 100644 index 00000000..32f763e0 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/additional_operations_before_32_spec.rb @@ -0,0 +1,74 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::AdditionalOperationsBefore32' do + def base_doc(openapi_version_string, path_item) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => { '/pets' => path_item }, + } + end + + def doc_with_additional_operations(openapi_version_string) + path_item = { + 'additionalOperations' => { + 'COPY' => { 'responses' => { '200' => { 'description' => 'OK' } } }, + }, + } + OpenAPIParser.parse(base_doc(openapi_version_string, path_item), strict_reference_validation: false) + end + + def doc_without_additional_operations(openapi_version_string) + path_item = { 'get' => { 'responses' => { '200' => { 'description' => 'OK' } } } } + OpenAPIParser.parse(base_doc(openapi_version_string, path_item), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::AdditionalOperationsBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using additionalOperations' do + it 'reports no violation' do + root = doc_with_additional_operations('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without additionalOperations' do + it 'reports no violation' do + root = doc_without_additional_operations('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using additionalOperations' do + it 'reports one violation pointing at the path item' do + root = doc_with_additional_operations('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/paths/~1pets/additionalOperations' + expect(violations.first.rule_name).to eq :additional_operations_before32 + end + end + + context 'with a 3.1 document without additionalOperations' do + it 'reports no violation' do + root = doc_without_additional_operations('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using additionalOperations' do + it 'reports one violation' do + root = doc_with_additional_operations('3.0.0') + expect(run_rule_for(root).size).to eq 1 + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_additional_operations('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end diff --git a/spec/openapi_parser/spec_validator/rules/query_method_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/query_method_before_32_spec.rb new file mode 100644 index 00000000..9f36ba76 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/query_method_before_32_spec.rb @@ -0,0 +1,70 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::QueryMethodBefore32' do + def base_doc(openapi_version_string, path_item) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => { '/pets' => path_item }, + } + end + + def doc_with_query(openapi_version_string) + path_item = { 'query' => { 'responses' => { '200' => { 'description' => 'OK' } } } } + OpenAPIParser.parse(base_doc(openapi_version_string, path_item), strict_reference_validation: false) + end + + def doc_without_query(openapi_version_string) + path_item = { 'get' => { 'responses' => { '200' => { 'description' => 'OK' } } } } + OpenAPIParser.parse(base_doc(openapi_version_string, path_item), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::QueryMethodBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using a query operation' do + it 'reports no violation' do + root = doc_with_query('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document without a query operation' do + it 'reports no violation' do + root = doc_without_query('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using a query operation' do + it 'reports one violation pointing at the path item' do + root = doc_with_query('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/paths/~1pets/query' + expect(violations.first.rule_name).to eq :query_method_before32 + end + end + + context 'with a 3.1 document without a query operation' do + it 'reports no violation' do + root = doc_without_query('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using a query operation' do + it 'reports one violation' do + root = doc_with_query('3.0.0') + expect(run_rule_for(root).size).to eq 1 + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_query('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end