diff --git a/CHANGELOG.md b/CHANGELOG.md index 259d7da2..4953da7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ * 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 * support root-level `jsonSchemaDialect` (OpenAPI 3.1) in the parse layer +* support `prefixItems` (OpenAPI 3.1) with positional tuple validation * add `SpecValidator` with `strict_specification_version` config (`:silent` / `:warn` / `:raise`) to detect version mismatches between declared OpenAPI version and actual field usage + * `PrefixItemsIn30`: detect `prefixItems` usage in 3.0 documents (3.1 addition) * `JsonSchemaDialectIn30`: detect root-level `jsonSchemaDialect` usage in 3.0 documents (3.1 addition) * `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) diff --git a/lib/openapi_parser/schema_validator/array_validator.rb b/lib/openapi_parser/schema_validator/array_validator.rb index 71df3901..1b16862e 100644 --- a/lib/openapi_parser/schema_validator/array_validator.rb +++ b/lib/openapi_parser/schema_validator/array_validator.rb @@ -11,10 +11,14 @@ def coerce_and_validate(value, schema, **_keyword_args) value, err = validate_unique_items(value, schema) return [nil, err] if err - # array type have an schema in items property + # 3.1: prefixItems positionally validates the leading N elements. + # Elements past the prefix fall back to schema.items, matching how + # JSON Schema 2020-12 layers the two keywords. + prefix_schemas = schema.prefix_items || [] items_schema = schema.items - coerced_values = value.map do |v| - coerced, err = validatable.validate_schema(v, items_schema) + coerced_values = value.each_with_index.map do |v, idx| + sub_schema = prefix_schemas[idx] || items_schema + coerced, err = validatable.validate_schema(v, sub_schema) return [nil, err] if err coerced diff --git a/lib/openapi_parser/schemas/schema.rb b/lib/openapi_parser/schemas/schema.rb index 17e57da7..5436a3fb 100644 --- a/lib/openapi_parser/schemas/schema.rb +++ b/lib/openapi_parser/schemas/schema.rb @@ -98,6 +98,10 @@ class Schema < Base # @return [Schema, nil] openapi_attr_object :items, Schema, reference: true + # @!attribute [r] prefix_items + # @return [Array, nil] tuple-style positional schemas (OpenAPI 3.1+) + openapi_attr_list_object :prefix_items, Schema, reference: true, schema_key: :prefixItems + # @!attribute [r] properties # @return [Hash{String => Schema}, nil] openapi_attr_hash_object :properties, Schema, reference: true diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index 9a8e7c67..fe7a8f2c 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -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/prefix_items_in_30' require_relative 'spec_validator/rules/json_schema_dialect_in_30' require_relative 'spec_validator/rules/type_array_in_30' require_relative 'spec_validator/rules/path_items_in_30' @@ -59,6 +60,7 @@ def rules [ Rules::ExclusiveMinimum, Rules::ExclusiveMaximum, + Rules::PrefixItemsIn30, Rules::JsonSchemaDialectIn30, Rules::TypeArrayIn30, Rules::PathItemsIn30, diff --git a/lib/openapi_parser/spec_validator/rules/prefix_items_in_30.rb b/lib/openapi_parser/spec_validator/rules/prefix_items_in_30.rb new file mode 100644 index 00000000..f0791c47 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/prefix_items_in_30.rb @@ -0,0 +1,25 @@ +module OpenAPIParser + class SpecValidator + module Rules + # `prefixItems` is JSON Schema 2020-12's positional tuple keyword. + # 3.1 adopts it; 3.0 has no equivalent and parsing it is a spec + # mismatch the validator should report. + class PrefixItemsIn30 < Rule + def check(root) + return [] unless version == :v3_0 + + violations = [] + each_schema(root) do |schema| + next unless schema.raw_schema.is_a?(Hash) && schema.raw_schema.key?('prefixItems') + + violations << violation( + path: schema.object_reference, + message: '`prefixItems` is a 3.1 addition (from JSON Schema 2020-12); 3.0 has no equivalent', + ) + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 10250d1a..e01ac6d5 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -45,6 +45,10 @@ module OpenAPIParser def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + class PrefixItemsIn30 < Rule + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end + class JsonSchemaDialectIn30 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end diff --git a/spec/data/openapi_3_1/prefix_items_30.yaml b/spec/data/openapi_3_1/prefix_items_30.yaml new file mode 100644 index 00000000..dfb7ea28 --- /dev/null +++ b/spec/data/openapi_3_1/prefix_items_30.yaml @@ -0,0 +1,25 @@ +openapi: 3.0.3 +info: + title: Geo API + version: '1.0' +paths: + /points: + post: + summary: Submit a coordinate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Coordinate' + responses: + '201': + description: Created +components: + schemas: + Coordinate: + # `prefixItems` is JSON Schema 2020-12's positional tuple keyword, + # adopted by 3.1. 3.0 has no equivalent, so this is a spec violation. + type: array + prefixItems: + - type: number + - type: number diff --git a/spec/data/openapi_3_1/prefix_items_31.yaml b/spec/data/openapi_3_1/prefix_items_31.yaml new file mode 100644 index 00000000..e73d53a6 --- /dev/null +++ b/spec/data/openapi_3_1/prefix_items_31.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Geo API + version: '1.0' +paths: + /points: + post: + summary: Submit a coordinate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Coordinate' + responses: + '201': + description: Created +components: + schemas: + Coordinate: + # `prefixItems` is legitimate under 3.1 (JSON Schema 2020-12), so no + # violation is expected here. + type: array + prefixItems: + - type: number + - type: number diff --git a/spec/openapi_parser/schema_validator/array_validator_spec.rb b/spec/openapi_parser/schema_validator/array_validator_spec.rb index b44a2341..8ef47fd2 100644 --- a/spec/openapi_parser/schema_validator/array_validator_spec.rb +++ b/spec/openapi_parser/schema_validator/array_validator_spec.rb @@ -68,4 +68,55 @@ end end end + + describe 'prefixItems tuple validation (3.1)' do + let(:tuple_schema) do + raw = { + 'openapi' => '3.1.0', + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { + 'schemas' => { + 'Tuple' => { + 'type' => 'array', + 'prefixItems' => [ + { 'type' => 'string' }, + { 'type' => 'integer' }, + ], + 'items' => { 'type' => 'boolean' }, + }, + }, + }, + } + OpenAPIParser.parse(raw, strict_reference_validation: false).components.schemas['Tuple'] + end + + context 'when the array obeys prefixItems exactly' do + it 'passes validation' do + expect(OpenAPIParser::SchemaValidator.validate(['a', 1], tuple_schema, options)).to eq(['a', 1]) + end + end + + context 'when an element fails its prefixItems schema' do + it 'raises a validation error' do + expect do + OpenAPIParser::SchemaValidator.validate(['a', 'not_an_integer'], tuple_schema, options) + end.to raise_error(OpenAPIParser::ValidateError) + end + end + + context 'when extra elements are validated against items' do + it 'passes when extras conform to items' do + expect(OpenAPIParser::SchemaValidator.validate(['a', 1, true, false], tuple_schema, options)).to eq(['a', 1, true, false]) + end + end + + context 'when extra elements violate items' do + it 'raises a validation error' do + expect do + OpenAPIParser::SchemaValidator.validate(['a', 1, 'not_boolean'], tuple_schema, options) + end.to raise_error(OpenAPIParser::ValidateError) + end + end + end end diff --git a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb index 78e6c14d..6b7ed5e4 100644 --- a/spec/openapi_parser/spec_validator/integration_3_1_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_1_spec.rb @@ -74,6 +74,20 @@ def expect_clean(file) end end + describe 'prefixItems (JSON Schema tuple keyword new in 3.1)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('prefix_items_30.yaml', [:prefix_items_in30]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('prefix_items_30.yaml', [:prefix_items_in30]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('prefix_items_31.yaml') + end + end + describe 'jsonSchemaDialect (3.1 root-level addition)' do it 'warns on the version-mismatched document under :warn' do expect_mismatch_warns('json_schema_dialect_30.yaml', [:json_schema_dialect_in30]) diff --git a/spec/openapi_parser/spec_validator/rules/prefix_items_in_30_spec.rb b/spec/openapi_parser/spec_validator/rules/prefix_items_in_30_spec.rb new file mode 100644 index 00000000..98d23f53 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/prefix_items_in_30_spec.rb @@ -0,0 +1,73 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::PrefixItemsIn30' do + def base_doc(openapi_version_string, sample_schema) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => {}, + 'components' => { 'schemas' => { 'Tuple' => sample_schema } }, + } + end + + def doc_with_prefix_items(openapi_version_string) + raw = base_doc( + openapi_version_string, + { + 'type' => 'array', + 'prefixItems' => [ + { 'type' => 'string' }, + { 'type' => 'integer' }, + ], + }, + ) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def doc_without_prefix_items(openapi_version_string) + raw = base_doc(openapi_version_string, { 'type' => 'array', 'items' => { 'type' => 'integer' } }) + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::PrefixItemsIn30.new(root.openapi_version).check(root) + end + + context 'with a 3.1 document using prefixItems' do + it 'reports no violation' do + root = doc_with_prefix_items('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document without prefixItems' do + it 'reports no violation' do + root = doc_without_prefix_items('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using prefixItems' do + it 'reports one violation pointing at the offending schema' do + root = doc_with_prefix_items('3.0.0') + violations = run_rule_for(root) + expect(violations.size).to eq 1 + expect(violations.first.path).to eq '#/components/schemas/Tuple' + expect(violations.first.rule_name).to eq :prefix_items_in30 + end + end + + context 'with a 3.0 document without prefixItems' do + it 'reports no violation' do + root = doc_without_prefix_items('3.0.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with an :unknown version document' do + it 'reports no violation (rule skipped)' do + root = doc_with_prefix_items('4.0.0') + expect(run_rule_for(root)).to eq [] + end + end +end