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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@
* `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
* support root-level `webhooks` (OpenAPI 3.1) in the parse layer
* 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
Expand Down
9 changes: 5 additions & 4 deletions lib/openapi_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions lib/openapi_parser/concerns/media_type_selectable.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
7 changes: 6 additions & 1 deletion lib/openapi_parser/concerns/schema_loader/creator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions lib/openapi_parser/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/openapi_parser/schemas/components.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 20 additions & 3 deletions lib/openapi_parser/schemas/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_parser/schemas/request_body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/openapi_parser/schemas/response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/openapi_parser/spec_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -95,6 +96,7 @@ def rules
Rules::XmlAttributeDeprecation,
Rules::XmlWrappedDeprecation,
Rules::SecuritySchemeFieldsBefore32,
Rules::MediaTypesBefore32,
]
end
end
Expand Down
24 changes: 24 additions & 0 deletions lib/openapi_parser/spec_validator/rules/media_types_before_32.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions sig/openapi_parser.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions sig/openapi_parser/config.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions spec/data/openapi_3_2/media_type_refs_external_30.yaml
Original file line number Diff line number Diff line change
@@ -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'
8 changes: 8 additions & 0 deletions spec/data/openapi_3_2/media_type_refs_external_32.yaml
Original file line number Diff line number Diff line change
@@ -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'
19 changes: 19 additions & 0 deletions spec/data/openapi_3_2/media_type_refs_fragment.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions spec/data/openapi_3_2/media_types_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:
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
33 changes: 33 additions & 0 deletions spec/data/openapi_3_2/media_types_32.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading