diff --git a/TODO.md b/TODO.md index 6ba43b4..d6cd98f 100644 --- a/TODO.md +++ b/TODO.md @@ -46,10 +46,10 @@ For OpenAPI 3.1 - [x] Support webhooks - [x] No longer require responses field on an Operation node - [x] Require OpenAPI node to have webhooks, paths or components -- [ ] Support the switch to a fixed schema dialect +- [x] Support the switch to a fixed schema dialect - [x] Support summary field on Info node - [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes -- [ ] jsonSchemaDialect should default to OAS one +- [x] jsonSchemaDialect should default to OAS one - [x] Allow summary and description in Reference objects - [x] Add identifier to License node, make mutually exclusive with URL - [x] ServerVariable enum must not be empty diff --git a/lib/openapi3_parser/document.rb b/lib/openapi3_parser/document.rb index 311ab47..cee2eb2 100644 --- a/lib/openapi3_parser/document.rb +++ b/lib/openapi3_parser/document.rb @@ -10,11 +10,12 @@ module Openapi3Parser # @attr_reader [Source] root_source # @attr_reader [Array] warnings # @attr_reader [Boolean] emit_warnings + # rubocop:disable Metrics/ClassLength class Document extend Forwardable include Enumerable - attr_reader :openapi_version, :root_source, :warnings, :emit_warnings + attr_reader :openapi_version, :root_source, :emit_warnings # A collection of the openapi versions that are supported SUPPORTED_OPENAPI_VERSIONS = %w[3.0 3.1].freeze @@ -92,7 +93,8 @@ def initialize(source_input, emit_warnings: true) @reference_registry = ReferenceRegistry.new @root_source = Source.new(source_input, self, reference_registry) @emit_warnings = emit_warnings - @warnings = [] + @build_warnings = [] + @unsupported_schema_dialects = Set.new @openapi_version = determine_openapi_version(root_source.data["openapi"]) @build_in_progress = false @built = false @@ -162,15 +164,35 @@ def node_at(pointer, relative_to = nil) look_up_pointer(pointer, relative_to, root) end + # An array of any warnings enountered in the initialisation / validation + # of the document. Reflects warnings related to this gems ability to parse + # the document. + # + # @return [Array] + def warnings + @warnings ||= begin + factory.errors # ensure factory has completed validation + @build_warnings.freeze + end + end + # @return [String] def inspect %{#{self.class.name}(openapi_version: #{openapi_version}, } + %{root_source: #{root_source.inspect})} end + #  :nodoc: + def unsupported_schema_dialect(schema_dialect) + return if @build_warnings.frozen? || unsupported_schema_dialects.include?(schema_dialect) + + unsupported_schema_dialects << schema_dialect + add_warning("Unsupported schema dialect (#{schema_dialect}), it may not parse or validate correctly.") + end + private - attr_reader :reference_registry, :built, :build_in_progress + attr_reader :reference_registry, :built, :build_in_progress, :unsupported_schema_dialects, :build_warnings def look_up_pointer(pointer, relative_pointer, subject) merged_pointer = Source::Pointer.merge_pointers(relative_pointer, @@ -179,8 +201,8 @@ def look_up_pointer(pointer, relative_pointer, subject) end def add_warning(text) - warn("Warning: #{text} - disable these by opening a document with emit_warnings: false") if emit_warnings - @warnings << text + warn("Warning: #{text} Disable these warnings by opening a document with emit_warnings: false.") if emit_warnings + @build_warnings << text end def build @@ -190,7 +212,6 @@ def build context = NodeFactory::Context.root(root_source.data, root_source) @factory = NodeFactory::Openapi.new(context) reference_registry.freeze - @warnings.freeze @build_in_progress = false @built = true end @@ -225,4 +246,5 @@ def reference_factories reference_registry.factories.reject { |f| f.context.source.root? } end end + # rubocop:enable Metrics/ClassLength end diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 2147f8c..dab869e 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -51,6 +51,16 @@ def false? boolean == false end + # The schema dialect in usage, only https://spec.openapis.org/oas/3.1/dialect/base + # is officially supported so others will receive a warning, but as + # long they don't have different data types for keywords they'll be + # mostly usable. + # + # @return [String] + def json_schema_dialect + self["$schema"] || node_context.document.json_schema_dialect + end + # @return [String, Node::Array, nil] def type self["type"] diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index 71ff427..2ed7282 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -5,6 +5,7 @@ require "openapi3_parser/node_factory/paths" require "openapi3_parser/node_factory/components" require "openapi3_parser/node_factory/external_documentation" +require "openapi3_parser/node_factory/schema/v3_1" module Openapi3Parser module NodeFactory @@ -14,7 +15,7 @@ class Openapi < NodeFactory::Object field "openapi", input_type: String, required: true field "info", factory: NodeFactory::Info, required: true field "jsonSchemaDialect", - default: "https://spec.openapis.org/oas/3.1/dialect/base", + default: Schema::V3_1::OAS_DIALECT, input_type: String, validate: Validation::InputValidator.new(Validators::Uri), allowed: ->(context) { context.openapi_version >= "3.1" } diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index d5a37ef..4efe15f 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -2,6 +2,7 @@ require "openapi3_parser/node_factory/object" require "openapi3_parser/node_factory/referenceable" +require "openapi3_parser/node_factory/schema/common" require "openapi3_parser/validators/media_type" module Openapi3Parser @@ -13,12 +14,16 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas include Referenceable include Schema::Common JSON_SCHEMA_ALLOWED_TYPES = %w[null boolean object array number string integer].freeze + OAS_DIALECT = "https://spec.openapis.org/oas/3.1/dialect/base" # Allows any extension as per: # https://github.com/OAI/OpenAPI-Specification/blob/a1facce1b3621df3630cb692e9fbe18a7612ea6d/versions/3.1.0.md#fixed-fields-20 allow_extensions(regex: /.*/) field "$ref", input_type: String, factory: :ref_factory + field "$schema", + input_type: String, + validate: Validation::InputValidator.new(Validators::Uri) field "type", factory: :type_factory, validate: :validate_type field "const" field "exclusiveMaximum", input_type: Numeric @@ -43,6 +48,17 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "unevaluatedItems", factory: :referenceable_schema field "unevaluatedProperties", factory: :referenceable_schema + validate do |validatable| + # if we do more with supporting $schema we probably want it to be + # a value in the context object so it can cascade appropariately + document = validatable.context.source_location.document + dialect = validatable.input["$schema"] || document.resolved_input_at("#/jsonSchemaDialect") + + next if dialect.nil? || dialect == OAS_DIALECT + + document.unsupported_schema_dialect(dialect.to_s) + end + def boolean_input? [true, false].include?(resolved_input) end diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb index 38cb6cd..a6213f4 100644 --- a/spec/integration/open_v3.1_examples_spec.rb +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -33,6 +33,33 @@ end end + context "when using the schema dialects example" do + let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "schema-dialects-example.yaml") } + + it "is valid but outputs warnings" do + expect { document.valid? }.to output.to_stderr + expect(document).to be_valid + end + + it "only warns once per dialect" do + expect { document.warnings }.to output.to_stderr + end + + it "defaults to using the the jsonSchemaDialect value" do + expect { document.warnings }.to output.to_stderr + expect(document.components.schemas["DefaultDialect"].json_schema_dialect) + .to eq(document.json_schema_dialect) + end + + it "can return the other schema dialects" do + expect { document.warnings }.to output.to_stderr + expect(document.components.schemas["DefinedDialect"].json_schema_dialect) + .to eq("https://spec.openapis.org/oas/3.1/dialect/base") + expect(document.components.schemas["CustomDialect1"].json_schema_dialect) + .to eq("https://example.com/custom-dialect") + end + end + context "when using the schema I created to demonstrate changes" do let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "changes.yaml") } diff --git a/spec/lib/openapi3_parser/document_spec.rb b/spec/lib/openapi3_parser/document_spec.rb index 77ae79c..b8b1f63 100644 --- a/spec/lib/openapi3_parser/document_spec.rb +++ b/spec/lib/openapi3_parser/document_spec.rb @@ -223,4 +223,67 @@ def raw_source_input(data) .to eq("1.0.0") end end + + describe "#warnings" do + it "returns a frozen array" do + instance = described_class.new(raw_source_input(source_data)) + expect(instance.warnings).to be_frozen + end + + it "has warnings from the input" do + source_data.merge!({ + "openapi" => "3.1.0", + "components" => { + "schemas" => { + "SchemaThatWillGenerateWarning" => { "$schema" => "https://example.com/unsupported-dialect" } + } + } + }) + + instance = described_class.new(raw_source_input(source_data)) + warnings = nil + # expect a warn to be emit + expect { warnings = instance.warnings }.to output.to_stderr + expect(warnings).to include(/Unsupported schema dialect/) + end + end + + describe "#unsupported_schema_dialect" do + let(:schema_dialect) { "path/to/dialect" } + let(:warning) { "Unsupported schema dialect (#{schema_dialect}), it may not parse or validate correctly." } + + it "adds a warning and outputs it" do + instance = described_class.new(raw_source_input(source_data)) + expect { instance.unsupported_schema_dialect(schema_dialect) } + .to output(/Unsupported schema dialect/).to_stderr + + expect(instance.warnings).to include(warning) + end + + it "adds a warning without outputting it if emit_warnings is false" do + instance = described_class.new(raw_source_input(source_data), emit_warnings: false) + expect { instance.unsupported_schema_dialect(schema_dialect) } + .not_to output.to_stderr + + expect(instance.warnings).to include(warning) + end + + it "does nothing if the schema dialect has already been registered" do + instance = described_class.new(raw_source_input(source_data), emit_warnings: false) + instance.unsupported_schema_dialect(schema_dialect) + + expect { instance.unsupported_schema_dialect(schema_dialect) } + .not_to(change { instance.warnings.count }) + end + + it "does nothing if warnings have already been frozen" do + instance = described_class.new(raw_source_input(source_data), emit_warnings: false) + instance.unsupported_schema_dialect(schema_dialect) + # accessing warnings will ensure it's frozen + expect(instance.warnings).to be_frozen + + expect { instance.unsupported_schema_dialect("other") } + .not_to(change { instance.warnings.count }) + end + end end diff --git a/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb index 08eeae6..2f20305 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb @@ -133,6 +133,122 @@ end end + describe "validating JSON schema dialect" do + let(:global_json_schema_dialect) { nil } + let(:document_input) do + { + "openapi" => "3.1.0", + "jsonSchemaDialect" => global_json_schema_dialect + } + end + let(:document) do + source_input = Openapi3Parser::SourceInput::Raw.new(document_input) + Openapi3Parser::Document.new(source_input) + end + + before { allow(document).to receive(:unsupported_schema_dialect) } + + context "when the $schema value is the OAS base one" do + it "doesn't flag the schema as an unsupported dialect" do + node_factory_context = create_node_factory_context( + { "$schema" => described_class::OAS_DIALECT }, + document: + ) + + instance = described_class.new(node_factory_context) + instance.valid? + + expect(document).not_to have_received(:unsupported_schema_dialect) + end + end + + context "when the $schema value is not the OAS base one" do + it "flags the schema as an unsupported dialect" do + node_factory_context = create_node_factory_context( + { "$schema" => "https://example.com/schema" }, + document: + ) + + instance = described_class.new(node_factory_context) + instance.valid? + + expect(document) + .to have_received(:unsupported_schema_dialect) + .with("https://example.com/schema") + end + + it "has a validation error if the schema dialect is not a valid URI" do + node_factory_context = create_node_factory_context( + { "$schema" => "not a URI" }, + document: + ) + + instance = described_class.new(node_factory_context) + + expect(instance) + .to have_validation_error("#/%24schema") + .with_message('"not a URI" is not a valid URI') + end + end + + context "when the $schema value is a non string" do + it "runs to_s to report it as an unsupported_schema_dialect" do + node_factory_context = create_node_factory_context( + { "$schema" => [] }, + document: + ) + + instance = described_class.new(node_factory_context) + instance.valid? + + expect(document) + .to have_received(:unsupported_schema_dialect) + .with("[]") + end + + it "has a validation error" do + node_factory_context = create_node_factory_context( + { "$schema" => [] }, + document: + ) + + instance = described_class.new(node_factory_context) + + expect(instance) + .to have_validation_error("#/%24schema") + .with_message("Invalid type. Expected String") + end + end + + context "when the $schema value is empty and the document has the OAS base one" do + let(:global_json_schema_dialect) { described_class::OAS_DIALECT } + + it "doesn't flag the schema as an unsupported dialect" do + node_factory_context = create_node_factory_context({}, document:) + + instance = described_class.new(node_factory_context) + instance.valid? + + expect(document).not_to have_received(:unsupported_schema_dialect) + end + end + + context "when the $schema value is empty and the document has a none OAS base one" do + let(:global_json_schema_dialect) { "https://example.com/schema" } + + it "doesn't flag the schema as an unsupported dialect" do + node_factory_context = create_node_factory_context({}, document:) + + instance = described_class.new(node_factory_context) + instance.valid? + + expect(document) + .to have_received(:unsupported_schema_dialect) + .with(global_json_schema_dialect) + end + end + end + describe "type field" do it "is valid for a string input of the 7 allowed types" do described_class::JSON_SCHEMA_ALLOWED_TYPES.each do |type| diff --git a/spec/support/examples/v3.1/schema-dialects-example.yaml b/spec/support/examples/v3.1/schema-dialects-example.yaml new file mode 100644 index 0000000..d475838 --- /dev/null +++ b/spec/support/examples/v3.1/schema-dialects-example.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Schema Dialects Example + version: 1.0.0 +jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema" +components: + schemas: + DefaultDialect: + type: string + AnotherDefaultDialect: + type: string + DefinedDialect: + $schema: "https://spec.openapis.org/oas/3.1/dialect/base" + type: string + CustomDialect1: + $schema: "https://example.com/custom-dialect" + type: string + CustomDialect2: + $schema: "https://example.com/custom-dialect" + type: string