Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## Unreleased
* support `components.pathItems` so `$ref`s into it resolve, unblocking OpenAPI 3.1 documents that use reusable path items
* support array-form `type` (3.1) in value validation
* add `SpecValidator` with `strict_specification_version` config (`:silent` / `:warn` / `:raise`) to detect version mismatches between declared OpenAPI version and actual field usage
* `TypeArrayIn30`: detect array-form `type` usage in 3.0 documents (3.1 form)
* `NullableDeprecation`: detect `nullable` usage in 3.1 documents (removed in 3.1)
* `ExampleSingularDeprecation`: detect singular `example` on schemas in 3.1 documents (deprecated in 3.1)
* `PathItemsIn30`: detect `components.pathItems` usage in 3.0 documents (3.1 addition)
Expand Down
37 changes: 32 additions & 5 deletions lib/openapi_parser/schema_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
require_relative 'schema_validator/all_of_validator'
require_relative 'schema_validator/one_of_validator'
require_relative 'schema_validator/nil_validator'
require_relative 'schema_validator/null_type_validator'
require_relative 'schema_validator/type_mismatch_validator'
require_relative 'schema_validator/unspecified_type_validator'

class OpenAPIParser::SchemaValidator
Expand Down Expand Up @@ -104,7 +104,20 @@ def validator(value, schema)
return one_of_validator if schema.one_of
return nil_validator if value.nil?

case schema.type
# 3.1: pick the type from an Array whose primitive class matches the
# value, then dispatch as if it were a single-type schema. If nothing
# matches we still pick a candidate so the relevant validator emits
# a sensible error rather than silently accepting via the unspecified
# fallback.
effective_type = schema.type
if effective_type.is_a?(Array)
matched = pick_array_type(value, effective_type)
return type_mismatch_validator if matched.nil?

effective_type = matched
end

case effective_type
when 'string'
string_validator
when 'integer'
Expand All @@ -121,14 +134,28 @@ def validator(value, schema)
# 3.1: only nil values are valid here. nil is handled earlier in
# this method, so a non-nil value reaching this branch is a type
# mismatch that should fail validation.
null_type_validator
type_mismatch_validator
else
unspecified_type_validator
end
end

def null_type_validator
@null_type_validator ||= OpenAPIParser::SchemaValidator::NullTypeValidator.new(self, @coerce_value)
def type_mismatch_validator
@type_mismatch_validator ||= OpenAPIParser::SchemaValidator::TypeMismatchValidator.new(self, @coerce_value)
end

def pick_array_type(value, types)
types.find do |t|
case t
when 'string' then value.is_a?(String)
when 'integer' then value.is_a?(Integer)
when 'number' then value.is_a?(Numeric)
when 'boolean' then value == true || value == false
when 'array' then value.is_a?(Array)
when 'object' then value.is_a?(Hash)
when 'null' then value.nil?
end
end
end

def string_validator
Expand Down
2 changes: 2 additions & 0 deletions lib/openapi_parser/schema_validator/nil_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ def coerce_and_validate(value, schema, **_keyword_args)
return [value, nil] if schema.nullable
# 3.1: `type: "null"` makes nil the only valid value for the schema.
return [value, nil] if schema.type == 'null'
# 3.1: `type: [..., "null"]` lets nil coexist with another primitive.
return [value, nil] if schema.type.is_a?(Array) && schema.type.include?('null')

[nil, OpenAPIParser::NotNullError.new(schema.object_reference)]
end
Expand Down
10 changes: 0 additions & 10 deletions lib/openapi_parser/schema_validator/null_type_validator.rb

This file was deleted.

11 changes: 11 additions & 0 deletions lib/openapi_parser/schema_validator/type_mismatch_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class OpenAPIParser::SchemaValidator
# Emits a ValidateError unconditionally. Used when the dispatcher has
# already decided that no primitive applies to the value, e.g.:
# - schema declares `type: "null"` and the value is non-nil (3.1)
# - schema declares `type: [t1, t2, ...]` and the value matches none
class TypeMismatchValidator < Base
def coerce_and_validate(value, schema, **_keyword_args)
OpenAPIParser::ValidateError.build_error_result(value, schema)
end
end
end
2 changes: 2 additions & 0 deletions lib/openapi_parser/spec_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
require_relative 'spec_validator/rule'
require_relative 'spec_validator/rules/exclusive_minimum'
require_relative 'spec_validator/rules/exclusive_maximum'
require_relative 'spec_validator/rules/type_array_in_30'
require_relative 'spec_validator/rules/path_items_in_30'
require_relative 'spec_validator/rules/nullable_deprecation'
require_relative 'spec_validator/rules/example_singular_deprecation'
Expand Down Expand Up @@ -55,6 +56,7 @@ def rules
[
Rules::ExclusiveMinimum,
Rules::ExclusiveMaximum,
Rules::TypeArrayIn30,
Rules::PathItemsIn30,
Rules::NullableDeprecation,
Rules::ExampleSingularDeprecation,
Expand Down
25 changes: 25 additions & 0 deletions lib/openapi_parser/spec_validator/rules/type_array_in_30.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module OpenAPIParser
class SpecValidator
module Rules
# 3.1 lets `type` be an Array of primitive type names (e.g.
# `["string", "null"]`). 3.0 only allowed a single String, so the
# array form on a 3.0 document is a spec violation.
class TypeArrayIn30 < Rule
def check(root)
return [] unless version == :v3_0

violations = []
each_schema(root) do |schema|
next unless schema.type.is_a?(Array)

violations << violation(
path: schema.object_reference,
message: '`type` as an Array of primitive names is a 3.1 addition; 3.0 expects a single type String',
)
end
violations
end
end
end
end
end
4 changes: 4 additions & 0 deletions sig/openapi_parser/spec_validator.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ module OpenAPIParser
def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation]
end

class TypeArrayIn30 < Rule
def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation]
end

class PathItemsIn30 < Rule
def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation]
end
Expand Down
24 changes: 24 additions & 0 deletions spec/data/openapi_3_1/type_array_30.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
openapi: 3.0.3
info:
title: Telemetry API
version: '1.0'
paths:
/readings:
get:
summary: List sensor readings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Reading'
components:
schemas:
Reading:
type: object
properties:
# 3.1 form: `type` as an Array of primitive names. 3.0 only allows a
# single type String, so the array form is a spec violation here.
label:
type: [string, "null"]
24 changes: 24 additions & 0 deletions spec/data/openapi_3_1/type_array_31.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
openapi: 3.1.0
info:
title: Telemetry API
version: '1.0'
paths:
/readings:
get:
summary: List sensor readings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Reading'
components:
schemas:
Reading:
type: object
properties:
# 3.1 allows `type` to be an Array of primitive names, so this is
# legitimate on a 3.1 document and no violation is expected.
label:
type: [string, "null"]
14 changes: 14 additions & 0 deletions spec/openapi_parser/spec_validator/integration_3_1_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ def expect_clean(file)
end
end

describe 'type as an Array of names (3.1 form rejected by 3.0)' do
it 'warns on the version-mismatched document under :warn' do
expect_mismatch_warns('type_array_30.yaml', [:type_array_in30])
end

it 'raises SpecViolationError on the version-mismatched document under :raise' do
expect_mismatch_raises('type_array_30.yaml', [:type_array_in30])
end

it 'stays clean on the correctly-versioned document' do
expect_clean('type_array_31.yaml')
end
end

describe 'components.pathItems (3.1 addition)' do
it 'warns on the version-mismatched document under :warn' do
expect_mismatch_warns('path_items_30.yaml', [:path_items_in30])
Expand Down
81 changes: 81 additions & 0 deletions spec/openapi_parser/spec_validator/rules/type_array_in_30_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
require_relative '../../../spec_helper'

RSpec.describe 'OpenAPIParser::SpecValidator::Rules::TypeArrayIn30' do
def schema_with_type_array(openapi_version_string, types)
raw = {
'openapi' => openapi_version_string,
'info' => { 'title' => 'test', 'version' => '1.0' },
'paths' => {},
'components' => { 'schemas' => { 'Sample' => { 'type' => types } } },
}
OpenAPIParser.parse(raw, strict_reference_validation: false)
end

def run_rule_for(root)
OpenAPIParser::SpecValidator::Rules::TypeArrayIn30.new(root.openapi_version).check(root)
end

context 'with a 3.1 document using type as Array' do
it 'reports no violation' do
root = schema_with_type_array('3.1.0', ['string', 'null'])
expect(run_rule_for(root)).to eq []
end
end

context 'with a 3.0 document using type as Array' do
it 'reports one violation pointing at the offending schema' do
root = schema_with_type_array('3.0.0', ['string', 'null'])
violations = run_rule_for(root)
expect(violations.size).to eq 1
expect(violations.first.path).to eq '#/components/schemas/Sample'
expect(violations.first.rule_name).to eq :type_array_in30
end
end

context 'with a 3.0 document using type as plain string' do
it 'reports no violation' do
root = schema_with_type_array('3.0.0', 'string')
expect(run_rule_for(root)).to eq []
end
end

context 'with an :unknown version document using type as Array' do
it 'reports no violation (rule skipped)' do
root = schema_with_type_array('4.0.0', ['string', 'null'])
expect(run_rule_for(root)).to eq []
end
end
end

RSpec.describe 'runtime: type as Array semantic in 3.1' do
let(:options) { ::OpenAPIParser::SchemaValidator::Options.new }
let(:schema) do
raw = {
'openapi' => '3.1.0',
'info' => { 'title' => 'test', 'version' => '1.0' },
'paths' => {},
'components' => { 'schemas' => { 'NullableString' => { 'type' => ['string', 'null'] } } },
}
OpenAPIParser.parse(raw, strict_reference_validation: false).components.schemas['NullableString']
end

context 'when value is nil and array contains "null"' do
it 'passes validation' do
expect(OpenAPIParser::SchemaValidator.validate(nil, schema, options)).to eq nil
end
end

context 'when value matches one of the listed types' do
it 'passes validation' do
expect(OpenAPIParser::SchemaValidator.validate('hello', schema, options)).to eq 'hello'
end
end

context 'when value matches no listed type' do
it 'raises a type-mismatch error' do
expect do
OpenAPIParser::SchemaValidator.validate(42, schema, options)
end.to raise_error(OpenAPIParser::ValidateError)
end
end
end