diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5c8f6..2d2c95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ * `SelfBefore32`: detect `$self` usage in pre-3.2 documents (3.2 addition) * `TagFieldsBefore32`: detect Tag Object `summary` / `parent` / `kind` usage in pre-3.2 documents (3.2 additions) * `ServerNameBefore32`: detect Server Object `name` usage in pre-3.2 documents (3.2 addition) + * `ExampleValueFieldsBefore32`: detect Example Object `dataValue` / `serializedValue` usage in pre-3.2 documents (3.2 additions) * 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 diff --git a/lib/openapi_parser/spec_validator.rb b/lib/openapi_parser/spec_validator.rb index b038113..5d0df2d 100644 --- a/lib/openapi_parser/spec_validator.rb +++ b/lib/openapi_parser/spec_validator.rb @@ -19,6 +19,7 @@ require_relative 'spec_validator/rules/self_before_32' require_relative 'spec_validator/rules/tag_fields_before_32' require_relative 'spec_validator/rules/server_name_before_32' +require_relative 'spec_validator/rules/example_value_fields_before_32' module OpenAPIParser class SpecViolationError < OpenAPIError @@ -85,6 +86,7 @@ def rules Rules::SelfBefore32, Rules::TagFieldsBefore32, Rules::ServerNameBefore32, + Rules::ExampleValueFieldsBefore32, ] end end diff --git a/lib/openapi_parser/spec_validator/rule.rb b/lib/openapi_parser/spec_validator/rule.rb index 3007195..204c9e0 100644 --- a/lib/openapi_parser/spec_validator/rule.rb +++ b/lib/openapi_parser/spec_validator/rule.rb @@ -56,6 +56,11 @@ def each_node(root, *klasses) end end + # escapes a map key for use in a violation path, matching object_reference + def escape_reference(key) + key.to_s.gsub('/', '~1') + end + def walk(node, visited, &block) return unless node.respond_to?(:_openapi_all_child_objects) return if visited[node.object_id] diff --git a/lib/openapi_parser/spec_validator/rules/example_value_fields_before_32.rb b/lib/openapi_parser/spec_validator/rules/example_value_fields_before_32.rb new file mode 100644 index 0000000..c54dd38 --- /dev/null +++ b/lib/openapi_parser/spec_validator/rules/example_value_fields_before_32.rb @@ -0,0 +1,48 @@ +module OpenAPIParser + class SpecValidator + module Rules + # 3.2 adds `dataValue` and `serializedValue` to the Example Object. + # Example Objects are not modeled by the parse layer, so this rule + # inspects the raw `examples` maps on Parameter, Media Type, and + # Header objects, plus `components.examples`. Media Types under a + # Parameter or Header `content` and anything under callbacks are not + # modeled, so examples there are not reached. + class ExampleValueFieldsBefore32 < Rule + NEW_FIELDS = %w[dataValue serializedValue].freeze + + def check(root) + return [] unless version_before?('3.2') + + violations = [] + node_classes = [ + OpenAPIParser::Schemas::Parameter, + OpenAPIParser::Schemas::MediaType, + OpenAPIParser::Schemas::Header, + OpenAPIParser::Schemas::Components, + ] + each_node(root, *node_classes) do |node| + raw = node.raw_schema + next unless raw.is_a?(Hash) + + examples = raw['examples'] + next unless examples.is_a?(Hash) + + examples.each do |name, example| + next unless example.is_a?(Hash) + + NEW_FIELDS.each do |field| + next unless example.key?(field) + + violations << violation( + path: "#{node.object_reference}/examples/#{escape_reference(name)}/#{field}", + message: "`#{field}` on an Example Object is a 3.2 addition; earlier documents have no such field", + ) + end + end + end + violations + end + end + end + end +end diff --git a/sig/openapi_parser/spec_validator.rbs b/sig/openapi_parser/spec_validator.rbs index 3b0e482..eb78f9e 100644 --- a/sig/openapi_parser/spec_validator.rbs +++ b/sig/openapi_parser/spec_validator.rbs @@ -36,6 +36,7 @@ module OpenAPIParser private def violation: (path: String, message: String) -> SpecViolation private def each_schema: (untyped root) ?{ (untyped) -> void } -> untyped private def each_node: (untyped root, *Class klasses) { (untyped) -> void } -> void + private def escape_reference: (untyped key) -> String private def walk: (untyped node, Hash[Integer, bool] visited) { (untyped) -> void } -> void end @@ -116,6 +117,11 @@ module OpenAPIParser class ServerNameBefore32 < Rule def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] end + + class ExampleValueFieldsBefore32 < Rule + NEW_FIELDS: Array[String] + def check: (OpenAPIParser::Schemas::OpenAPI root) -> Array[SpecValidator::SpecViolation] + end end end diff --git a/spec/data/openapi_3_2/example_value_fields_31.yaml b/spec/data/openapi_3_2/example_value_fields_31.yaml new file mode 100644 index 0000000..dfa2ebb --- /dev/null +++ b/spec/data/openapi_3_2/example_value_fields_31.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + post: + summary: Create a pet + requestBody: + content: + application/json: + schema: + type: object + examples: + dog: + summary: A dog + # `dataValue` and `serializedValue` on an Example Object are + # 3.2 additions; a 3.1 document has no such fields, so their + # use here is a spec violation. + dataValue: + name: Rex + serializedValue: '{"name":"Rex"}' + responses: + '201': + description: Created diff --git a/spec/data/openapi_3_2/example_value_fields_32.yaml b/spec/data/openapi_3_2/example_value_fields_32.yaml new file mode 100644 index 0000000..c16414a --- /dev/null +++ b/spec/data/openapi_3_2/example_value_fields_32.yaml @@ -0,0 +1,24 @@ +openapi: 3.2.0 +info: + title: Pet API + version: '1.0' +paths: + /pets: + post: + summary: Create a pet + requestBody: + content: + application/json: + schema: + type: object + examples: + dog: + summary: A dog + # `dataValue` and `serializedValue` are legitimate Example + # Object fields under 3.2, so no violation is expected here. + dataValue: + name: Rex + serializedValue: '{"name":"Rex"}' + responses: + '201': + description: Created 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 43c882e..9c0c61d 100644 --- a/spec/openapi_parser/spec_validator/integration_3_2_spec.rb +++ b/spec/openapi_parser/spec_validator/integration_3_2_spec.rb @@ -80,4 +80,18 @@ def expect_clean(file) expect_clean('server_name_32.yaml') end end + + describe 'Example Object dataValue/serializedValue (3.2 additions)' do + it 'warns on the version-mismatched document under :warn' do + expect_mismatch_warns('example_value_fields_31.yaml', [:example_value_fields_before32, :example_value_fields_before32]) + end + + it 'raises SpecViolationError on the version-mismatched document under :raise' do + expect_mismatch_raises('example_value_fields_31.yaml', [:example_value_fields_before32, :example_value_fields_before32]) + end + + it 'stays clean on the correctly-versioned document' do + expect_clean('example_value_fields_32.yaml') + end + end end diff --git a/spec/openapi_parser/spec_validator/rules/example_value_fields_before_32_spec.rb b/spec/openapi_parser/spec_validator/rules/example_value_fields_before_32_spec.rb new file mode 100644 index 0000000..7899032 --- /dev/null +++ b/spec/openapi_parser/spec_validator/rules/example_value_fields_before_32_spec.rb @@ -0,0 +1,111 @@ +require_relative '../../../spec_helper' + +RSpec.describe 'OpenAPIParser::SpecValidator::Rules::ExampleValueFieldsBefore32' do + def base_doc(openapi_version_string, example) + { + 'openapi' => openapi_version_string, + 'info' => { 'title' => 'test', 'version' => '1.0' }, + 'paths' => { + '/pets' => { + 'post' => { + 'requestBody' => { + 'content' => { + 'application/json' => { + 'schema' => { 'type' => 'object' }, + 'examples' => { 'dog' => example }, + }, + }, + }, + 'responses' => { '201' => { 'description' => 'Created' } }, + }, + }, + }, + } + end + + def doc_with_value_fields(openapi_version_string) + example = { + 'summary' => 'A dog', + 'dataValue' => { 'name' => 'Rex' }, + 'serializedValue' => '{"name":"Rex"}', + } + OpenAPIParser.parse(base_doc(openapi_version_string, example), strict_reference_validation: false) + end + + def doc_without_value_fields(openapi_version_string) + example = { 'summary' => 'A dog', 'value' => { 'name' => 'Rex' } } + OpenAPIParser.parse(base_doc(openapi_version_string, example), strict_reference_validation: false) + end + + def run_rule_for(root) + OpenAPIParser::SpecValidator::Rules::ExampleValueFieldsBefore32.new(root.openapi_version).check(root) + end + + context 'with a 3.2 document using dataValue and serializedValue' do + it 'reports no violation' do + root = doc_with_value_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.2 document using only the classic value field' do + it 'reports no violation' do + root = doc_without_value_fields('3.2.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.1 document using dataValue and serializedValue' do + it 'reports one violation per offending field pointing at the example' do + root = doc_with_value_fields('3.1.0') + violations = run_rule_for(root) + expect(violations.size).to eq 2 + expect(violations.map(&:path)).to match_array [ + '#/paths/~1pets/post/requestBody/content/application~1json/examples/dog/dataValue', + '#/paths/~1pets/post/requestBody/content/application~1json/examples/dog/serializedValue', + ] + expect(violations.first.rule_name).to eq :example_value_fields_before32 + end + end + + context 'with a 3.1 document using only the classic value field' do + it 'reports no violation' do + root = doc_without_value_fields('3.1.0') + expect(run_rule_for(root)).to eq [] + end + end + + context 'with a 3.0 document using dataValue and serializedValue' do + it 'reports violations' do + root = doc_with_value_fields('3.0.0') + expect(run_rule_for(root).size).to eq 2 + end + end + + context 'with a 3.1 document using the new fields on parameters, headers, and components' do + let(:example) { { 'dataValue' => { 'name' => 'Rex' } } } + let(:root) do + raw = base_doc('3.1.0', { 'value' => {} }) + post = raw['paths']['/pets']['post'] + post['parameters'] = [{ 'name' => 'q', 'in' => 'query', 'schema' => { 'type' => 'string' }, 'examples' => { 'rex' => example } }] + post['responses']['201']['headers'] = { 'X-Id' => { 'schema' => { 'type' => 'string' }, 'examples' => { 'rex' => example } } } + raw['components'] = { 'examples' => { 'shared/rex' => example } } + OpenAPIParser.parse(raw, strict_reference_validation: false) + end + + it 'reports each location, escaping slashes in example names' do + expect(run_rule_for(root).map(&:path)).to match_array [ + '#/paths/~1pets/post/parameters/0/examples/rex/dataValue', + '#/paths/~1pets/post/responses/201/headers/X-Id/examples/rex/dataValue', + '#/components/examples/shared~1rex/dataValue', + ] + end + end + + context 'with a document whose openapi field is not a version' do + it 'reports no violation (rule skipped)' do + root = doc_with_value_fields('not-a-version') + expect(run_rule_for(root)).to eq [] + end + end +end