diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff7bfa..b4622b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ * `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) * 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 * 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.5.0 (2026-10-21) * add OpenAPI 3.2 `SpecValidator` rules diff --git a/lib/openapi_parser/schemas/path_item.rb b/lib/openapi_parser/schemas/path_item.rb index 3cb9c71..d424cd2 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 500ecdd..2c8e6ea 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -28,6 +28,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 @@ -103,6 +105,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 0000000..4739af5 --- /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 0000000..61e2694 --- /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 3ba7ed0..c081c40 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -159,6 +159,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 0000000..91f120f --- /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 0000000..097c2f3 --- /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 0000000..771e3b3 --- /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 0000000..ec4c555 --- /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 28ca6a2..7e65334 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 6567dc3..50f2234 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 ca693bb..e4fe148 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 0000000..32f763e --- /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 0000000..9f36ba7 --- /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