Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
38 changes: 32 additions & 6 deletions lib/openapi_parser/schemas/path_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions lib/openapi_parser/spec_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -101,6 +103,8 @@ def rules
Rules::MediaTypesBefore32,
Rules::StreamingFieldsBefore32,
Rules::DefaultMappingBefore32,
Rules::QueryMethodBefore32,
Rules::AdditionalOperationsBefore32,
]
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions lib/openapi_parser/spec_validator/rules/query_method_before_32.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions spec/data/openapi_3_2/additional_operations_31.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions spec/data/openapi_3_2/additional_operations_32.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions spec/data/openapi_3_2/query_method_31.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions spec/data/openapi_3_2/query_method_32.yaml
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions spec/openapi_parser/request_operation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions spec/openapi_parser/schemas/path_item_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions spec/openapi_parser/spec_validator/integration_3_2_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading