From 0cb50b5361e3d9f0f5709c2595f97849baefd71d Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 24 Dec 2021 16:09:55 +0000 Subject: [PATCH 01/53] Add 3.1 to list of supported versions --- lib/openapi3_parser/document.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openapi3_parser/document.rb b/lib/openapi3_parser/document.rb index be8f6ea2..ecf4a9b9 100644 --- a/lib/openapi3_parser/document.rb +++ b/lib/openapi3_parser/document.rb @@ -16,7 +16,7 @@ class Document attr_reader :openapi_version, :root_source, :warnings # A collection of the openapi versions that are supported - SUPPORTED_OPENAPI_VERSIONS = %w[3.0].freeze + SUPPORTED_OPENAPI_VERSIONS = %w[3.0 3.1].freeze # The version of OpenAPI that will be used by default for # validation/construction From 58256ed81970c84d9fca4f9bba5f5b104fca0015 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 24 Dec 2021 16:16:35 +0000 Subject: [PATCH 02/53] Put v3.0 examples into a specific directory --- spec/integration/open_a_yaml_document_spec.rb | 2 +- spec/integration/open_a_yaml_url_document_spec.rb | 2 +- spec/support/examples/{ => v3.0}/petstore-expanded.yaml | 0 spec/support/examples/{ => v3.0}/uber.yaml | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename spec/support/examples/{ => v3.0}/petstore-expanded.yaml (100%) rename spec/support/examples/{ => v3.0}/uber.yaml (100%) diff --git a/spec/integration/open_a_yaml_document_spec.rb b/spec/integration/open_a_yaml_document_spec.rb index 11691048..3523ce9c 100644 --- a/spec/integration/open_a_yaml_document_spec.rb +++ b/spec/integration/open_a_yaml_document_spec.rb @@ -2,7 +2,7 @@ RSpec.describe "Open a YAML Document" do let(:document) { Openapi3Parser.load_file(path) } - let(:path) { File.join(__dir__, "..", "support", "examples", "uber.yaml") } + let(:path) { File.join(__dir__, "..", "support", "examples", "v3.0", "uber.yaml") } it "is a valid document" do expect(document).to be_valid diff --git a/spec/integration/open_a_yaml_url_document_spec.rb b/spec/integration/open_a_yaml_url_document_spec.rb index 437817f4..cfd1677e 100644 --- a/spec/integration/open_a_yaml_url_document_spec.rb +++ b/spec/integration/open_a_yaml_url_document_spec.rb @@ -6,7 +6,7 @@ before do path = File.join( - __dir__, "..", "support", "examples", "petstore-expanded.yaml" + __dir__, "..", "support", "examples", "v3.0", "petstore-expanded.yaml" ) stub_request(:get, "example.com/openapi.yml") .to_return(body: File.read(path)) diff --git a/spec/support/examples/petstore-expanded.yaml b/spec/support/examples/v3.0/petstore-expanded.yaml similarity index 100% rename from spec/support/examples/petstore-expanded.yaml rename to spec/support/examples/v3.0/petstore-expanded.yaml diff --git a/spec/support/examples/uber.yaml b/spec/support/examples/v3.0/uber.yaml similarity index 100% rename from spec/support/examples/uber.yaml rename to spec/support/examples/v3.0/uber.yaml From 9ec9260a09afcae3947e3f3036b7822a452c2cc3 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 24 Dec 2021 16:30:07 +0000 Subject: [PATCH 03/53] Add some 3.1 integration tests --- spec/integration/open_v3.1_examples_spec.rb | 35 +++++++++++++++++++ .../examples/v3.1/non-oauth-scopes.yaml | 19 ++++++++++ .../examples/v3.1/webhook-example.yaml | 34 ++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 spec/integration/open_v3.1_examples_spec.rb create mode 100644 spec/support/examples/v3.1/non-oauth-scopes.yaml create mode 100644 spec/support/examples/v3.1/webhook-example.yaml diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb new file mode 100644 index 00000000..d2f9ea8b --- /dev/null +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe "Open v3.1 examples" do + let(:document) { Openapi3Parser.load_url(url) } + let(:url) { "http://example.com/openapi.yml" } + + before do + stub_request(:get, "example.com/openapi.yml") + .to_return(body: File.read(path)) + end + + context "when using the webhook example" do + let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "webhook-example.yaml") } + + it "is a valid document" do + expect(document).to be_valid + end + + it "can access the version" do + expect(document.openapi).to eq "3.1.0" + end + end + + context "when using the non-oauth scope example" do + let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "non-oauth-scopes.yaml") } + + it "is a valid document" do + expect(document).to be_valid + end + + it "can access the version" do + expect(document.openapi).to eq "3.1.0" + end + end +end diff --git a/spec/support/examples/v3.1/non-oauth-scopes.yaml b/spec/support/examples/v3.1/non-oauth-scopes.yaml new file mode 100644 index 00000000..e757452f --- /dev/null +++ b/spec/support/examples/v3.1/non-oauth-scopes.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Non-oAuth Scopes example + version: 1.0.0 +paths: + /users: + get: + security: + - bearerAuth: + - 'read:users' + - 'public' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: jwt + description: 'note: non-oauth scopes are not defined at the securityScheme level' + diff --git a/spec/support/examples/v3.1/webhook-example.yaml b/spec/support/examples/v3.1/webhook-example.yaml new file mode 100644 index 00000000..44fc73aa --- /dev/null +++ b/spec/support/examples/v3.1/webhook-example.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Webhook Example + version: 1.0.0 +# Since OAS 3.1.0 the paths element isn't necessary. Now a valid OpenAPI Document can describe only paths, webhooks, or even only reusable components +webhooks: + # Each webhook needs a name + newPet: + # This is a Path Item Object, the only difference is that the request is initiated by the API provider + post: + requestBody: + description: Information about a new pet in the system + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Return a 200 status to indicate that the data was received successfully + +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string From b97bc98eb0cd06f5bd4cf51fe6cbe5ae0a97f1c5 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Tue, 15 Mar 2022 19:45:52 +0000 Subject: [PATCH 04/53] Add #openapi_version methods to context classes These are intended to be used as ways to determine whether particular OpenAPI features will be enabled. --- lib/openapi3_parser/node/context.rb | 7 +++++++ lib/openapi3_parser/node_factory/context.rb | 7 +++++++ lib/openapi3_parser/source/location.rb | 1 + spec/lib/openapi3_parser/node/context_spec.rb | 8 ++++++++ .../node_factory/context_spec.rb | 17 +++++++++++++++++ 5 files changed, 40 insertions(+) diff --git a/lib/openapi3_parser/node/context.rb b/lib/openapi3_parser/node/context.rb index e805433d..9eee66a6 100644 --- a/lib/openapi3_parser/node/context.rb +++ b/lib/openapi3_parser/node/context.rb @@ -154,6 +154,13 @@ def parent_node relative_node("#..") end + + # Returns the version of OpenAPI being used + # + # @return [String] + def openapi_version + document.openapi_version + end end end end diff --git a/lib/openapi3_parser/node_factory/context.rb b/lib/openapi3_parser/node_factory/context.rb index d8771e73..723adf26 100644 --- a/lib/openapi3_parser/node_factory/context.rb +++ b/lib/openapi3_parser/node_factory/context.rb @@ -101,6 +101,13 @@ def self_referencing? reference_locations.include?(source_location) end + # Returns the version of OpenAPI being used + # + # @return [String] + def openapi_version + source_location.document.openapi_version + end + def inspect %{#{self.class.name}(source_location: #{source_location}, } + %{referenced_by: #{reference_locations.map(&:to_s).join(', ')})} diff --git a/lib/openapi3_parser/source/location.rb b/lib/openapi3_parser/source/location.rb index 2663f241..db3db983 100644 --- a/lib/openapi3_parser/source/location.rb +++ b/lib/openapi3_parser/source/location.rb @@ -15,6 +15,7 @@ def self.next_field(location, field) end def_delegators :pointer, :root? + def_delegators :source, :document attr_reader :source, :pointer # @param [Openapi3Parser::Source] source diff --git a/spec/lib/openapi3_parser/node/context_spec.rb b/spec/lib/openapi3_parser/node/context_spec.rb index e605ede6..eb190c79 100644 --- a/spec/lib/openapi3_parser/node/context_spec.rb +++ b/spec/lib/openapi3_parser/node/context_spec.rb @@ -192,4 +192,12 @@ expect(instance.parent_node).to be_nil end end + + describe "#openapi_version" do + it "returns the document's OpenAPI version" do + instance = create_node_context({}, document_input: { "openapi" => "3.1.0" }) + + expect(instance.openapi_version).to eq("3.1") + end + end end diff --git a/spec/lib/openapi3_parser/node_factory/context_spec.rb b/spec/lib/openapi3_parser/node_factory/context_spec.rb index 2baaf543..99794c4f 100644 --- a/spec/lib/openapi3_parser/node_factory/context_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/context_spec.rb @@ -117,4 +117,21 @@ .to be_a(Openapi3Parser::Source::ResolvedReference) end end + + describe "#openapi_version" do + it "returns the document's OpenAPI version" do + input = { + "openapi" => "3.0.0", + "info" => { + "title" => "Test", + "version" => "1.0" + }, + "paths" => {} + } + source_location = create_source_location(input) + + instance = described_class.new({}, source_location: source_location) + expect(instance.openapi_version).to eq("3.0") + end + end end From aee527809716394123401d8ee73ddb5ef6ad0d4f Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Tue, 15 Mar 2022 20:18:14 +0000 Subject: [PATCH 05/53] Add OpenapiVersion class for version comparisons This class is to replace the use of a string as the means for representing the version of an OpenAPI version. A new class has been added to make it simpler to compare versions in a more sophisticated way than strings. The class inherits from Gem::Version which is a part of Ruby stdlib which has this comparison logic built in. The spaceship operator method has been replaced with one that will apply polymorphish to comparisons. E.g: It replaces the need for: ``` OpenapiVersion.new("3.11") > OpenapiVersion.new("3.2") ``` with: ``` OpenapiVersion.new("3.11") > "3.2" ``` I added this in as it felt more natural to do this than have some contrived `equal_or_greater_than?` methods. However I do have a concern that I may have created some Ruby magic --- lib/openapi3_parser/document.rb | 16 ++++++++-------- lib/openapi3_parser/openapi_version.rb | 16 ++++++++++++++++ spec/lib/openapi3_parser/openapi_version_spec.rb | 9 +++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 lib/openapi3_parser/openapi_version.rb create mode 100644 spec/lib/openapi3_parser/openapi_version_spec.rb diff --git a/lib/openapi3_parser/document.rb b/lib/openapi3_parser/document.rb index ecf4a9b9..d4793f26 100644 --- a/lib/openapi3_parser/document.rb +++ b/lib/openapi3_parser/document.rb @@ -6,9 +6,9 @@ module Openapi3Parser # Document is the root construct of a created OpenAPI Document and can be # used to navigate the contents of a document or to check it's validity. # - # @attr_reader [String] openapi_version - # @attr_reader [Source] root_source - # @attr_reader [Array] warnings + # @attr_reader [OpenapiVersion] openapi_version + # @attr_reader [Source] root_source + # @attr_reader [Array] warnings class Document extend Forwardable include Enumerable @@ -187,21 +187,21 @@ def build def determine_openapi_version(version) minor_version = (version || "").split(".").first(2).join(".") - if SUPPORTED_OPENAPI_VERSIONS.include?(minor_version) - minor_version - elsif version + return OpenapiVersion.new(minor_version) if SUPPORTED_OPENAPI_VERSIONS.include?(minor_version) + + if version add_warning( "Unsupported OpenAPI version (#{version}), treating as a " \ "#{DEFAULT_OPENAPI_VERSION} document" ) - DEFAULT_OPENAPI_VERSION else add_warning( "Unspecified OpenAPI version, treating as a " \ "#{DEFAULT_OPENAPI_VERSION} document" ) - DEFAULT_OPENAPI_VERSION end + + OpenapiVersion.new(DEFAULT_OPENAPI_VERSION) end def factory diff --git a/lib/openapi3_parser/openapi_version.rb b/lib/openapi3_parser/openapi_version.rb new file mode 100644 index 00000000..e97e4a1e --- /dev/null +++ b/lib/openapi3_parser/openapi_version.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Openapi3Parser + class OpenapiVersion < Gem::Version + # Converts the subject of a comparsion to be a OpenapiVersion object + # to provide shorthand comparisions such as: + # + # > OpenapiVersion.new("3.0") > "2.9" + # => true + # + # @return [Boolean] + def <=>(other) + super self.class.new(other) + end + end +end diff --git a/spec/lib/openapi3_parser/openapi_version_spec.rb b/spec/lib/openapi3_parser/openapi_version_spec.rb new file mode 100644 index 00000000..7ea7e988 --- /dev/null +++ b/spec/lib/openapi3_parser/openapi_version_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::OpenapiVersion do + it "provides ability to compare against primitive types" do + expect(described_class.new("3.12")).to be > "3.9" + expect(described_class.new("3.1")).to be >= 3.1 + expect(described_class.new("3.0")).not_to be < "3" + end +end From dca077de0457e1d0779c97d17e6a5c9b7f44b764 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Tue, 15 Mar 2022 21:03:27 +0000 Subject: [PATCH 06/53] Required option in field config accepts lambda Previously this argument only accepted boolean arguments and didn't allow you to specify that this could execute code. This code changes that by allowing lambda functions and symbols (that reference methods) as arguments. This has been added so that specifying whether a field is required can be determined by the openapi_version. --- lib/openapi3_parser/node_factory/object.rb | 2 +- .../object_factory/field_config.rb | 13 +++++++-- .../object_factory/field_config_spec.rb | 29 +++++++++++++++---- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/lib/openapi3_parser/node_factory/object.rb b/lib/openapi3_parser/node_factory/object.rb index 73628585..ee7e2490 100644 --- a/lib/openapi3_parser/node_factory/object.rb +++ b/lib/openapi3_parser/node_factory/object.rb @@ -65,7 +65,7 @@ def allowed_fields def required_fields field_configs.each_with_object([]) do |(key, config), memo| - memo << key if config.required? + memo << key if config.required?(context, self) end end diff --git a/lib/openapi3_parser/node_factory/object_factory/field_config.rb b/lib/openapi3_parser/node_factory/object_factory/field_config.rb index 2c48a8df..c7f3516a 100644 --- a/lib/openapi3_parser/node_factory/object_factory/field_config.rb +++ b/lib/openapi3_parser/node_factory/object_factory/field_config.rb @@ -33,8 +33,17 @@ def initialize_factory(context, parent_factory = nil) end end - def required? - given_required + def required?(context, factory) + required = case given_required + when Proc + given_required.call(context) + when Symbol + factory.send(given_required, context) + else + given_required + end + + !!required end def check_input_type(validatable, building_node: false) diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb index 62c0093c..a7c33459 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb @@ -56,14 +56,33 @@ def create_contact_validatable(node_factory_context = nil) end describe "#required?" do - it "returns true when the class is initialised with required" do + let(:context) { create_node_factory_context({ "name" => "Mike" }) } + let(:factory) { Openapi3Parser::NodeFactory::Contact.new(context) } + + it "returns false when a required value isn't provided" do + expect(described_class.new.required?(context, factory)).to be(false) + end + + it "returns a value when one is provided" do instance = described_class.new(required: true) - expect(instance.required?).to be(true) + expect(instance.required?(context, factory)).to be(true) end - it "returns false when the class is initialised without required" do - instance = described_class.new - expect(instance.required?).to be(false) + it "converts non boolean values into booleans" do + instance = described_class.new(required: nil) + expect(instance.required?(context, factory)).to be(false) + end + + it "calls the function when a callable is given" do + allow(context).to receive(:required?).and_return(true) + instance = described_class.new(required: ->(context) { context.required? }) + expect(instance.required?(context, factory)).to be(true) + end + + it "calls the method on the factory when a symbol is given" do + allow(factory).to receive(:my_factory_required).and_return(true) + instance = described_class.new(required: :my_factory_required) + expect(instance.required?(context, factory)).to be(true) end end From 8109b18292820bb1cfe775840ac745f35c86197f Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Tue, 15 Mar 2022 22:59:43 +0000 Subject: [PATCH 07/53] Only require paths for OpenAPI < 3.1 As of OpenAPI 3.1 paths is no longer required [1] [1]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-object --- lib/openapi3_parser/node/openapi.rb | 2 +- lib/openapi3_parser/node_factory/openapi.rb | 4 +++- .../node_factory/openapi_spec.rb | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/openapi3_parser/node/openapi.rb b/lib/openapi3_parser/node/openapi.rb index ced3c9fc..69bf4ba0 100644 --- a/lib/openapi3_parser/node/openapi.rb +++ b/lib/openapi3_parser/node/openapi.rb @@ -22,7 +22,7 @@ def servers self["servers"] end - # @return [Paths] + # @return [Paths, nil] def paths self["paths"] end diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index c9ae4a3a..b9877390 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -14,7 +14,9 @@ class Openapi < NodeFactory::Object field "openapi", input_type: String, required: true field "info", factory: NodeFactory::Info, required: true field "servers", factory: :servers_factory - field "paths", factory: NodeFactory::Paths, required: true + field "paths", + factory: NodeFactory::Paths, + required: ->(context) { context.openapi_version < "3.1" } field "components", factory: NodeFactory::Components field "security", factory: :security_factory field "tags", factory: :tags_factory diff --git a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb index 0bddbe13..5da9837f 100644 --- a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb @@ -93,6 +93,23 @@ end end + describe "OpenAPI version 3.1" do + it "is valid without the paths parameter" do + factory_context = create_node_factory_context( + { + "openapi" => "3.0.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + } + } + ) + + instance = described_class.new(factory_context) + expect(instance).to be_valid + end + end + def create_node(input) node_factory_context = create_node_factory_context(input) instance = described_class.new(node_factory_context) From 12bd103f014c6a50bbdec3f70f5bfdffa82da0ad Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Tue, 15 Mar 2022 23:25:49 +0000 Subject: [PATCH 08/53] Add a webhooks field to the root OpenAPI node This is a new field that has been added in OpenAPI 3.1 [1] [1]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oasWebhooks --- lib/openapi3_parser/node/openapi.rb | 5 +++++ lib/openapi3_parser/node_factory/openapi.rb | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/openapi3_parser/node/openapi.rb b/lib/openapi3_parser/node/openapi.rb index 69bf4ba0..6b57265d 100644 --- a/lib/openapi3_parser/node/openapi.rb +++ b/lib/openapi3_parser/node/openapi.rb @@ -27,6 +27,11 @@ def paths self["paths"] end + # @return [Node::Map, nil] + def webhooks + self["webhooks"] + end + # @return [Components] def components self["components"] diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index b9877390..a4cf0837 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -17,6 +17,7 @@ class Openapi < NodeFactory::Object field "paths", factory: NodeFactory::Paths, required: ->(context) { context.openapi_version < "3.1" } + field "webhooks", factory: :webhooks_factory field "components", factory: NodeFactory::Components field "security", factory: :security_factory field "tags", factory: :tags_factory @@ -39,6 +40,13 @@ def servers_factory(context) value_factory: NodeFactory::Server) end + def webhooks_factory(context) + NodeFactory::Map.new( + context, + value_factory: NodeFactory::OptionalReference.new(NodeFactory::PathItem) + ) + end + def security_factory(context) NodeFactory::Array.new(context, value_factory: NodeFactory::SecurityRequirement) From c3d6e9be775ef5eb9ff8da011e0842c503e31cf1 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 01:14:45 +0000 Subject: [PATCH 09/53] Fields have a concept of allowed This feature allows fields of a node to be marked as whether they are allowed to exist or not. This has been added in to support the OpenAPI 3.1 where there are fields that are only valid in OpenAPI 3.1 documents. A field can take an allowed value of a boolean, a proc or a symbol. It is expected that, outside of contrived tests, it will never use a plain boolean as there is no point marking a field as not allowed everywhere. --- lib/openapi3_parser/node_factory/object.rb | 10 +++-- .../object_factory/field_config.rb | 21 +++++++++- .../object_factory/field_config_spec.rb | 31 ++++++++++++++ .../node_factory/object_spec.rb | 41 ++++++++++++++++++- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/lib/openapi3_parser/node_factory/object.rb b/lib/openapi3_parser/node_factory/object.rb index ee7e2490..c8176f54 100644 --- a/lib/openapi3_parser/node_factory/object.rb +++ b/lib/openapi3_parser/node_factory/object.rb @@ -60,11 +60,11 @@ def default end def allowed_fields - field_configs.keys + allowed_field_configs.keys end def required_fields - field_configs.each_with_object([]) do |(key, config), memo| + allowed_field_configs.each_with_object([]) do |(key, config), memo| memo << key if config.required?(context, self) end end @@ -75,6 +75,10 @@ def inspect private + def allowed_field_configs + field_configs.select { |_, fc| fc.allowed?(context, self) } + end + def build_data(raw_input) use_default = nil_input? || !raw_input.is_a?(::Hash) return if use_default && default.nil? @@ -83,7 +87,7 @@ def build_data(raw_input) end def process_data(raw_data) - field_configs.each_with_object(raw_data.dup) do |(field, config), memo| + allowed_field_configs.each_with_object(raw_data.dup) do |(field, config), memo| memo[field] = nil unless memo.key?(field) next unless config.factory? diff --git a/lib/openapi3_parser/node_factory/object_factory/field_config.rb b/lib/openapi3_parser/node_factory/object_factory/field_config.rb index c7f3516a..102c87a8 100644 --- a/lib/openapi3_parser/node_factory/object_factory/field_config.rb +++ b/lib/openapi3_parser/node_factory/object_factory/field_config.rb @@ -4,19 +4,23 @@ module Openapi3Parser module NodeFactory module ObjectFactory class FieldConfig + # rubocop:disable Metrics/ParameterLists def initialize( input_type: nil, factory: nil, + allowed: true, required: false, default: nil, validate: nil ) @given_input_type = input_type @given_factory = factory + @given_allowed = allowed @given_required = required @given_default = default @given_validate = validate end + # rubocop:enable Metrics/ParameterLists def factory? !given_factory.nil? @@ -33,6 +37,19 @@ def initialize_factory(context, parent_factory = nil) end end + def allowed?(context, factory) + allowed = case given_allowed + when Proc + given_allowed.call(context) + when Symbol + factory.send(given_allowed, context) + else + given_allowed + end + + !!allowed + end + def required?(context, factory) required = case given_required when Proc @@ -80,8 +97,8 @@ def default(factory = nil) private - attr_reader :given_input_type, :given_factory, :given_required, - :given_default, :given_validate + attr_reader :given_input_type, :given_factory, :given_allowed, + :given_required, :given_default, :given_validate def run_validation(validatable) if given_validate.is_a?(Symbol) diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb index a7c33459..1d454dfb 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb @@ -55,6 +55,37 @@ def create_contact_validatable(node_factory_context = nil) end end + describe "#allowed?" do + let(:context) { create_node_factory_context({ "name" => "Mike" }) } + let(:factory) { Openapi3Parser::NodeFactory::Contact.new(context) } + + it "returns true when an allowed value isn't provided" do + expect(described_class.new.allowed?(context, factory)).to be(true) + end + + it "returns a value when one is provided" do + instance = described_class.new(allowed: false) + expect(instance.allowed?(context, factory)).to be(false) + end + + it "converts non boolean values into booleans" do + instance = described_class.new(allowed: nil) + expect(instance.allowed?(context, factory)).to be(false) + end + + it "calls the function when a callable is given" do + allow(context).to receive(:allowed?).and_return(false) + instance = described_class.new(allowed: ->(context) { context.allowed? }) + expect(instance.allowed?(context, factory)).to be(false) + end + + it "calls the method on the factory when a symbol is given" do + allow(factory).to receive(:my_factory_allowed).and_return(false) + instance = described_class.new(allowed: :my_factory_allowed) + expect(instance.allowed?(context, factory)).to be(false) + end + end + describe "#required?" do let(:context) { create_node_factory_context({ "name" => "Mike" }) } let(:factory) { Openapi3Parser::NodeFactory::Contact.new(context) } diff --git a/spec/lib/openapi3_parser/node_factory/object_spec.rb b/spec/lib/openapi3_parser/node_factory/object_spec.rb index d611ebe9..e87ca1bf 100644 --- a/spec/lib/openapi3_parser/node_factory/object_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_spec.rb @@ -4,5 +4,44 @@ let(:node_factory_context) { create_node_factory_context({}) } let(:instance) { described_class.new(node_factory_context) } - it_behaves_like "node factory", Hash + it_behaves_like "node factory", ::Hash + + describe "#allowed_fields" do + it "returns the keys of fields that are allowed" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + field "a", allowed: true + field "b", allowed: false + field "c", allowed: true + end + + instance = factory_class.new(create_node_factory_context({})) + + expect(instance.allowed_fields).to match_array(%w[a c]) + end + end + + describe "#required_fields" do + it "returns the keys of fields that are required" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + field "a", required: true + field "b", required: false + field "c", required: true + end + + instance = factory_class.new(create_node_factory_context({})) + + expect(instance.required_fields).to match_array(%w[a c]) + end + + it "only returns allowed fields" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + field "a", required: true, allowed: true + field "b", required: true, allowed: false + end + + instance = factory_class.new(create_node_factory_context({})) + + expect(instance.required_fields).to match_array(%w[a]) + end + end end From a0f747ca244c1a864c0c503886324f0edf540ec4 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 01:29:56 +0000 Subject: [PATCH 10/53] Only allow webhooks on OpenAPI >= 3.1 documents This field was introduced with OpenAPI 3.1 --- lib/openapi3_parser/node_factory/openapi.rb | 4 +- .../node_factory/openapi_spec.rb | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index a4cf0837..47cf4185 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -17,7 +17,9 @@ class Openapi < NodeFactory::Object field "paths", factory: NodeFactory::Paths, required: ->(context) { context.openapi_version < "3.1" } - field "webhooks", factory: :webhooks_factory + field "webhooks", + factory: :webhooks_factory, + allowed: ->(context) { context.openapi_version >= "3.1" } field "components", factory: NodeFactory::Components field "security", factory: :security_factory field "tags", factory: :tags_factory diff --git a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb index 5da9837f..b37f8c4d 100644 --- a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb @@ -93,16 +93,53 @@ end end + describe "webhooks field" do + it "accepts this field for OpenAPI >= 3.1" do + factory_context = create_node_factory_context( + { + "openapi" => "3.1.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + }, + "webhooks" => {} + }, + document_input: { "openapi" => "3.1.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).to be_valid + end + + it "rejects this field for OpenAPI < 3.1" do + factory_context = create_node_factory_context( + { + "openapi" => "3.0.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + }, + "webhooks" => {} + }, + document_input: { "openapi" => "3.0.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).not_to be_valid + end + end + describe "OpenAPI version 3.1" do it "is valid without the paths parameter" do factory_context = create_node_factory_context( { - "openapi" => "3.0.0", + "openapi" => "3.1.0", "info" => { "title" => "Minimal Openapi definition", "version" => "1.0.0" } - } + }, + document_input: { "openapi" => "3.1.0" } ) instance = described_class.new(factory_context) From 11994dc8b9343ff7256d59c283930038aef986bc Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 11:05:12 +0000 Subject: [PATCH 11/53] Start an OpenAPI v3.1 checklist --- TODO.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TODO.md b/TODO.md index d943e7fb..b32f8b57 100644 --- a/TODO.md +++ b/TODO.md @@ -39,3 +39,12 @@ These are the steps defined to reach 1.0. Assistance is very welcome. - [ ] Support validating a Server URL based on default values - [ ] Validate paths to check path parameters within them appear in paths see: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#fixed-fields-10 + +For OpenAPI 3.1 + +- [x] Conditional nodes +- [x] Support webhooks +- [ ] Require OpenAPI node to have webhooks, paths or components +- [ ] Support the switch to a fixed schema dialect +- [ ] Support infoSummary +- [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes From 93cab13aa4cdf6e1226cfa5db8a9350130f450e0 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 11:28:21 +0000 Subject: [PATCH 12/53] Don't require responses on an Operation in OpenAPI 3.1 A change introduced in OpenAPI 3.1 is that the responses field is no longer required [1] [1]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#operation-object --- TODO.md | 1 + lib/openapi3_parser/node_factory/operation.rb | 2 +- .../node_factory/operation_spec.rb | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index b32f8b57..f4ab0bd5 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,7 @@ For OpenAPI 3.1 - [x] Conditional nodes - [x] Support webhooks +- [x] No longer require responses field on an Operation node - [ ] Require OpenAPI node to have webhooks, paths or components - [ ] Support the switch to a fixed schema dialect - [ ] Support infoSummary diff --git a/lib/openapi3_parser/node_factory/operation.rb b/lib/openapi3_parser/node_factory/operation.rb index bdff9233..714e69f3 100644 --- a/lib/openapi3_parser/node_factory/operation.rb +++ b/lib/openapi3_parser/node_factory/operation.rb @@ -16,7 +16,7 @@ class Operation < NodeFactory::Object field "parameters", factory: :parameters_factory field "requestBody", factory: :request_body_factory field "responses", factory: NodeFactory::Responses, - required: true + required: ->(context) { context.openapi_version < "3.1" } field "callbacks", factory: :callbacks_factory field "deprecated", input_type: :boolean, default: false field "security", factory: :security_factory diff --git a/spec/lib/openapi3_parser/node_factory/operation_spec.rb b/spec/lib/openapi3_parser/node_factory/operation_spec.rb index 30930cf2..3595379d 100644 --- a/spec/lib/openapi3_parser/node_factory/operation_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/operation_spec.rb @@ -211,4 +211,20 @@ end end end + + describe "responses field" do + it "requires this field for OpenAPI 3.0" do + context = create_node_factory_context({}, document_input: { "openapi" => "3.0.0" }) + instance = described_class.new(context) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/") + .with_message("Missing required fields: responses") + end + + it "doesn't require this field for OpenAPI > 3.0" do + context = create_node_factory_context({}, document_input: { "openapi" => "3.1.0" }) + expect(described_class.new(context)).to be_valid + end + end end From ecd50e06de855ed00eb411cadaa9285cbfe7d77f Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 12:16:37 +0000 Subject: [PATCH 13/53] Require at least one of components, paths and webhooks This is a requirement from OpenAPI v3.1: The OpenAPI document MUST contain at least one paths field, a components field or a webhooks field. [1] [1]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#openapi-document --- TODO.md | 2 +- lib/openapi3_parser/node_factory/openapi.rb | 7 ++++++ .../node_factory/openapi_spec.rb | 24 +++++++++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index f4ab0bd5..e77cb7f2 100644 --- a/TODO.md +++ b/TODO.md @@ -45,7 +45,7 @@ For OpenAPI 3.1 - [x] Conditional nodes - [x] Support webhooks - [x] No longer require responses field on an Operation node -- [ ] Require OpenAPI node to have webhooks, paths or components +- [x] Require OpenAPI node to have webhooks, paths or components - [ ] Support the switch to a fixed schema dialect - [ ] Support infoSummary - [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index 47cf4185..b410e21c 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -25,6 +25,13 @@ class Openapi < NodeFactory::Object field "tags", factory: :tags_factory field "externalDocs", factory: NodeFactory::ExternalDocumentation + validate do |validatable| + next if validatable.context.openapi_version < "3.1" + next if (validatable.input.keys & %w[components paths webhooks]).any? + + validatable.add_error("At least one of components, paths and webhooks fields are required") + end + def can_use_default? false end diff --git a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb index b37f8c4d..1afa9f07 100644 --- a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb @@ -129,7 +129,7 @@ end end - describe "OpenAPI version 3.1" do + describe "OpenAPI version > 3.0" do it "is valid without the paths parameter" do factory_context = create_node_factory_context( { @@ -137,7 +137,8 @@ "info" => { "title" => "Minimal Openapi definition", "version" => "1.0.0" - } + }, + "components" => {} }, document_input: { "openapi" => "3.1.0" } ) @@ -145,6 +146,25 @@ instance = described_class.new(factory_context) expect(instance).to be_valid end + + it "requires paths, webhooks or components" do + factory_context = create_node_factory_context( + { + "openapi" => "3.1.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + } + }, + document_input: { "openapi" => "3.1.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/") + .with_message("At least one of components, paths and webhooks fields are required") + end end def create_node(input) From c94e21c11359ae1635d73abd59f9a39e72d378af Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 16:32:48 +0000 Subject: [PATCH 14/53] Add summary field to Info for OpenAPI v3.1 https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#info-object --- TODO.md | 2 +- lib/openapi3_parser/node/info.rb | 7 +++++++ lib/openapi3_parser/node_factory/info.rb | 3 +++ .../openapi3_parser/node_factory/info_spec.rb | 21 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index e77cb7f2..f95084f6 100644 --- a/TODO.md +++ b/TODO.md @@ -47,5 +47,5 @@ For OpenAPI 3.1 - [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 -- [ ] Support infoSummary +- [x] Support summary field on Info node - [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes diff --git a/lib/openapi3_parser/node/info.rb b/lib/openapi3_parser/node/info.rb index d0eb19b3..891c7719 100644 --- a/lib/openapi3_parser/node/info.rb +++ b/lib/openapi3_parser/node/info.rb @@ -11,6 +11,13 @@ def title self["title"] end + # Field introduced in OpenAPI v3.1 + # + # @return [String, nil] + def summary + self["summary"] + end + # @return [String, nil] def description self["description"] diff --git a/lib/openapi3_parser/node_factory/info.rb b/lib/openapi3_parser/node_factory/info.rb index e5fa4a0e..c8d26ff2 100644 --- a/lib/openapi3_parser/node_factory/info.rb +++ b/lib/openapi3_parser/node_factory/info.rb @@ -11,6 +11,9 @@ module NodeFactory class Info < NodeFactory::Object allow_extensions field "title", input_type: String, required: true + field "summary", + input_type: String, + allowed: ->(context) { context.openapi_version >= "3.1" } field "description", input_type: String field "termsOfService", input_type: String, diff --git a/spec/lib/openapi3_parser/node_factory/info_spec.rb b/spec/lib/openapi3_parser/node_factory/info_spec.rb index 508c0392..fd92bd0e 100644 --- a/spec/lib/openapi3_parser/node_factory/info_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/info_spec.rb @@ -34,4 +34,25 @@ expect(instance).to have_validation_error("#/termsOfService") end end + + describe "summary field" do + it "accepts a summary field for OpenAPI >= v3.1" do + factory_context = create_node_factory_context( + minimal_info_definition.merge({ "summary" => "summary contents" }), + document_input: { "openapi" => "3.1.0" } + ) + expect(described_class.new(factory_context)).to be_valid + end + + it "rejects a summary field for OpenAPI < v3.1" do + factory_context = create_node_factory_context( + minimal_info_definition.merge({ "summary" => "summary contents" }), + document_input: { "openapi" => "3.0.0" } + ) + instance = described_class.new(factory_context) + + expect(instance).not_to be_valid + expect(instance).to have_validation_error("#/").with_message("Unexpected fields: summary") + end + end end From 5c2d043386f5efe05d256731adf1a9a8960fba5e Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 17:10:42 +0000 Subject: [PATCH 15/53] Fix typo on "at least" --- lib/openapi3_parser/node_factory/server_variable.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openapi3_parser/node_factory/server_variable.rb b/lib/openapi3_parser/node_factory/server_variable.rb index 9cafe534..09398c69 100644 --- a/lib/openapi3_parser/node_factory/server_variable.rb +++ b/lib/openapi3_parser/node_factory/server_variable.rb @@ -20,7 +20,7 @@ def enum_factory(context) validate: lambda do |validatable| return if validatable.input.any? - validatable.add_error("Expected atleast one value") + validatable.add_error("Expected at least one value") end ) end From 16a9806bea86b07a30131a3b11e8a8c2e627b7c4 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 16 Mar 2022 19:49:18 +0000 Subject: [PATCH 16/53] Put together notes on OpenAPI 3.1 This is an attempt to try catalogue the changes that are needed - the most substantial of which seem to be in the Schema object. --- TODO.md | 12 +++ json-schema-for-3.1.md | 92 +++++++++++++++++++++ spec/integration/open_v3.1_examples_spec.rb | 12 +++ spec/support/examples/v3.1/changes.yaml | 80 ++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 json-schema-for-3.1.md create mode 100644 spec/support/examples/v3.1/changes.yaml diff --git a/TODO.md b/TODO.md index f95084f6..36b6723a 100644 --- a/TODO.md +++ b/TODO.md @@ -49,3 +49,15 @@ For OpenAPI 3.1 - [ ] 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 +- [ ] Allow summary and description in Reference objects +- [ ] Add identifier to License node, make mutually exclusive with URL +- [ ] ServerVariable enum must not be empty +- [ ] Add pathItems to components +- [ ] Callbacks can now reference a PathItem - previously required them +- [ ] Check out whether pathItem references match the rules for relative resolution +- [ ] Parameter object can have space delimited or pipeDelimited styles +- [ ] Discriminator object can be extended +- [ ] mutualTLS as a security scheme +- [ ] I think strictness of Security Requirement rules has changed + diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md new file mode 100644 index 00000000..e53c2532 --- /dev/null +++ b/json-schema-for-3.1.md @@ -0,0 +1,92 @@ +# JSON schema with 3.1 + +Temporary document to be removed with the merge of support for OpenAPI 3.1 + +Things have got complex with schemas in OpenAPI 3.1 + +How things might work: + +- when a schema factory is created, it determines whether the dialect is suported +- it then creates a factory based on the dialect +- if there is a reference in it this is resolved +- there could be complexities in the resolving process because of the id field - does it become relative to this? +- skip dynamicAnchor and dynamicRef for now - they are quite complex: https://stackoverflow.com/questions/69728686/explanation-of-dynamicref-dynamicanchor-in-json-schema-as-opposed-to-ref-and +- lets allow extra properties for schema since it's complex +- there's all the $defs stuff but this might just work as being a type of reference - presumably not used in OpenAPI anyway really + +So how might we start: + +- Perhaps add a class method to Schema which can identify which Schema factory is used: a OAS 3.1 one, an optionally referenced OAS 3.0 one, or non optional reference (if such a need exists), based on context. Error if given an unexpected dialect +- Learn whether you have to care about $id for resolving +- Create a node factory for OAS 3.1 Schema: + - allow arbitrary fields perhaps? Probably not needed, just a pain to keep up with JsonSchema + - load a merged reference + - perhaps have context support a merge concept for source location +- Think about dealing with recursive defined as "#" + +Dealing with the new JSON Schema approach for OpenAPI 3.1. + +There is some meta fields: + +$ref +$dynamicRef +$defs +$schema +$id +$comment +$anchor +$dynamicAnchor + +Then a ton of fields: + +type: string +enum: array +const: any type +multipleOf: number +maximum: number +exclusiveMaximum: number +minimum: number +exclusiveMinimum: number +maxLength: integer >= 0 +minLength: integer >= 0 +pattern: string +maxItems: integer >= 0 +minItems: integer >= 0 +uniqueItems: boolean +maxContains: integer >= 0 +minContains: integer >= 0 +maxProperties: integer >= 0 +minProperties: integer >= 0 +required: array, strings, unique +dependentRequired: something complex +contentEncoding: string +contentMediaType: string / media type +contentSchema: schema +title: string +description: string +default: any +deprecated: boolean (default false) +readOnly: boolean (default false) +writeOnly: boolean (default false) +examples: array + + +allOf - non empty array of schemas +anyOf - non empty array of schemas +oneOf - non empty array of schemas +not - schema + +if - single schema +then - single schema +else - single schema + +prefixItems: schema +items: schema +contains: schema + +properties: object, each value json schema +patternProperties: object each value JSON schema +additionalProperties: single json schema + +unevaluatedItems - single schema +unevaluatedProperties: single schema diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb index d2f9ea8b..bf8127c7 100644 --- a/spec/integration/open_v3.1_examples_spec.rb +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -32,4 +32,16 @@ expect(document.openapi).to eq "3.1.0" end end + + context "when using the schema I created to demonstrate changes" do + let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "changes.yaml") } + + xit "is a valid document" do + expect(document).to be_valid + end + + xit "can access the version" do + expect(document.openapi).to eq "3.1.0" + end + end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml new file mode 100644 index 00000000..c4cfcf05 --- /dev/null +++ b/spec/support/examples/v3.1/changes.yaml @@ -0,0 +1,80 @@ +openapi: 3.1.0 +info: + title: Examples of changes in OpenAPI 3.1 + summary: 3.1 introduced a summary field to the Info node + version: 1.0.0 + # license: + # name: Apache 2.0 + # identifier: Apache-2.0 +# jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +components: + schemas: + BasicSchema: + description: "My basic schema" + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + ReferencedSchema: + description: "My referenced schema" + $ref: "#/components/schemas/BasicSchema" + DoubleReferencedSchema: + description: "My double referenced schema" + $ref: "#/components/schemas/ReferencedSchema" + AnotherSchema: + $ref: "#/components/schemas/DoubleReferencedSchema" + AndAnotherSchema: + $ref: "#/components/schemas/AnotherSchema" + SelfReferencingSchema: + type: object + properties: + recursive_item: + $ref: "#/components/schemas/SelfReferencingSchema" + # WithDialect: + # $schema: https://spec.openapis.org/oas/3.1/dialect/base + # type: string + # Const: + # const: "test" + # MultipleTypes: + # type: + # - string + # - null + # Number: + # type: integer + # multipleOf: 5 + # maximum: 110 + # exclusiveMaximum: 111 + # minimum: 10 + # exclusiveMinimum: 9 + # String: + # type: string + # maxLength: 10 + # minLength: 5 + # pattern: "[a-z]*" + # Array: + # type: array + # maxItems: 10 + # minItems: 1 + # uniqueItems: true + # contains: + # const: "test" + # minContains: 1 + # maxContains: 1 + # prefixItems: + # - const: "item" + # type: string + # items: + # type: string + # unevaluatedItems: + # type: string + # Add object + # Add content types + # Add $ref usage (plain, merged, defs) + # Add compound things: anyOf, oneOf, not, if, then, else From a96a7330a56f17014a0cccc78374c4bf5058c24e Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 20 Mar 2022 11:22:56 +0000 Subject: [PATCH 17/53] Breaking: Create OpenAPI version specific Schema class With OpenAPI 3.1 the schema object is quite different (conforming to 2012-12 spec of JSON Schema). I think the easiest way to deal with this is to create separate classes for working with it. This starts this process by creating Schema::V3_0 classes which is a rename of the existing Schema classes. --- lib/openapi3_parser/node/schema.rb | 242 +---------------- lib/openapi3_parser/node/schema/v3_0.rb | 250 ++++++++++++++++++ .../node_factory/components.rb | 6 +- .../node_factory/media_type.rb | 3 +- .../node_factory/parameter_like.rb | 3 +- lib/openapi3_parser/node_factory/schema.rb | 124 +-------- .../node_factory/schema/v3_0.rb | 131 +++++++++ ...document_with_recursive_references_spec.rb | 8 +- .../{schema_spec.rb => schema/v3_0_spec.rb} | 6 +- .../node_factory/context_spec.rb | 2 +- .../{schema_spec.rb => schema/v3_0_spec.rb} | 4 +- 11 files changed, 402 insertions(+), 377 deletions(-) create mode 100644 lib/openapi3_parser/node/schema/v3_0.rb create mode 100644 lib/openapi3_parser/node_factory/schema/v3_0.rb rename spec/lib/openapi3_parser/node/{schema_spec.rb => schema/v3_0_spec.rb} (94%) rename spec/lib/openapi3_parser/node_factory/{schema_spec.rb => schema/v3_0_spec.rb} (98%) diff --git a/lib/openapi3_parser/node/schema.rb b/lib/openapi3_parser/node/schema.rb index 87bd7083..371bc45c 100644 --- a/lib/openapi3_parser/node/schema.rb +++ b/lib/openapi3_parser/node/schema.rb @@ -1,248 +1,8 @@ # frozen_string_literal: true -require "openapi3_parser/node/object" - module Openapi3Parser module Node - # @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject - # rubocop:disable Metrics/ClassLength - class Schema < Node::Object - # This is used to provide a name for the schema based on it's position in - # an OpenAPI document. - # - # For example it's common to have an OpenAPI document structured like so: - # components: - # schemas: - # Product: - # properties: - # product_id: - # type: string - # description: - # type: string - # - # and there is then implied meaning in the field name of Product, ie - # that schema now represents a product. This data is not easily or - # consistently made available as it is part of the path to the data - # rather than the data itself. Instead the field that would be more - # appropriate would be "title" within a schema. - # - # As this is a common pattern in OpenAPI docs this provides a method - # to look up this contextual name of the schema so it can be referenced - # when working with the document, it only considers a field to be - # name if it is within a group called schemas (as is the case - # in #/components/schemas) - # - # @return [String, nil] - def name - segments = node_context.source_location.pointer.segments - segments[-1] if segments[-2] == "schemas" - end - - # @return [String, nil] - def title - self["title"] - end - - # @return [Numeric, nil] - def multiple_of - self["multipleOf"] - end - - # @return [Integer, nil] - def maximum - self["maximum"] - end - - # @return [Boolean] - def exclusive_maximum? - self["exclusiveMaximum"] - end - - # @return [Integer, nil] - def minimum - self["minimum"] - end - - # @return [Boolean] - def exclusive_minimum? - self["exclusiveMinimum"] - end - - # @return [Integer, nil] - def max_length - self["maxLength"] - end - - # @return [Integer] - def min_length - self["minLength"] - end - - # @return [String, nil] - def pattern - self["pattern"] - end - - # @return [Integer, nil] - def max_items - self["maxItems"] - end - - # @return [Integer] - def min_items - self["minItems"] - end - - # @return [Boolean] - def unique_items? - self["uniqueItems"] - end - - # @return [Integer, nil] - def max_properties - self["maxProperties"] - end - - # @return [Integer] - def min_properties - self["minProperties"] - end - - # @return [Node::Array, nil] - def required - self["required"] - end - - # Returns whether a property is a required field or not. Can accept the - # property name or a schema - # - # @param [String, Schema] property - # @return [Boolean] - def requires?(property) - if property.is_a?(Schema) - # compare node_context of objects to ensure references aren't treated - # as equal - only direct properties of this object will pass. - properties.to_h - .slice(*required.to_a) - .any? { |_, schema| schema.node_context == property.node_context } - else - required.to_a.include?(property) - end - end - - # @return [Node::Array, nil] - def enum - self["enum"] - end - - # @return [String, nil] - def type - self["type"] - end - - # @return [Node::Array, nil] - def all_of - self["allOf"] - end - - # @return [Node::Array, nil] - def one_of - self["oneOf"] - end - - # @return [Node::Array, nil] - def any_of - self["anyOf"] - end - - # @return [Schema, nil] - def not - self["not"] - end - - # @return [Schema, nil] - def items - self["items"] - end - - # @return [Map] - def properties - self["properties"] - end - - # @return [Boolean] - def additional_properties? - self["additionalProperties"] != false - end - - # @return [Schema, nil] - def additional_properties_schema - properties = self["additionalProperties"] - return if [true, false].include?(properties) - - properties - end - - # @return [String, nil] - def description - self["description"] - end - - # @return [String, nil] - def description_html - render_markdown(description) - end - - # @return [String, nil] - def format - self["format"] - end - - # @return [Any] - def default - self["default"] - end - - # @return [Boolean] - def nullable? - self["nullable"] - end - - # @return [Discriminator, nil] - def discriminator - self["discriminator"] - end - - # @return [Boolean] - def read_only? - self["readOnly"] - end - - # @return [Boolean] - def write_only? - self["writeOnly"] - end - - # @return [Xml, nil] - def xml - self["xml"] - end - - # @return [ExternalDocumentation, nil] - def external_docs - self["externalDocs"] - end - - # @return [Any] - def example - self["example"] - end - - # @return [Boolean] - def deprecated? - self["deprecated"] - end + module Schema end - # rubocop:enable Metrics/ClassLength end end diff --git a/lib/openapi3_parser/node/schema/v3_0.rb b/lib/openapi3_parser/node/schema/v3_0.rb new file mode 100644 index 00000000..6d8bafd4 --- /dev/null +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require "openapi3_parser/node/object" + +module Openapi3Parser + module Node + module Schema + # @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject + # rubocop:disable Metrics/ClassLength + class V3_0 < Node::Object # rubocop:disable Naming/ClassAndModuleCamelCase + # This is used to provide a name for the schema based on it's position in + # an OpenAPI document. + # + # For example it's common to have an OpenAPI document structured like so: + # components: + # schemas: + # Product: + # properties: + # product_id: + # type: string + # description: + # type: string + # + # and there is then implied meaning in the field name of Product, ie + # that schema now represents a product. This data is not easily or + # consistently made available as it is part of the path to the data + # rather than the data itself. Instead the field that would be more + # appropriate would be "title" within a schema. + # + # As this is a common pattern in OpenAPI docs this provides a method + # to look up this contextual name of the schema so it can be referenced + # when working with the document, it only considers a field to be + # name if it is within a group called schemas (as is the case + # in #/components/schemas) + # + # @return [String, nil] + def name + segments = node_context.source_location.pointer.segments + segments[-1] if segments[-2] == "schemas" + end + + # @return [String, nil] + def title + self["title"] + end + + # @return [Numeric, nil] + def multiple_of + self["multipleOf"] + end + + # @return [Integer, nil] + def maximum + self["maximum"] + end + + # @return [Boolean] + def exclusive_maximum? + self["exclusiveMaximum"] + end + + # @return [Integer, nil] + def minimum + self["minimum"] + end + + # @return [Boolean] + def exclusive_minimum? + self["exclusiveMinimum"] + end + + # @return [Integer, nil] + def max_length + self["maxLength"] + end + + # @return [Integer] + def min_length + self["minLength"] + end + + # @return [String, nil] + def pattern + self["pattern"] + end + + # @return [Integer, nil] + def max_items + self["maxItems"] + end + + # @return [Integer] + def min_items + self["minItems"] + end + + # @return [Boolean] + def unique_items? + self["uniqueItems"] + end + + # @return [Integer, nil] + def max_properties + self["maxProperties"] + end + + # @return [Integer] + def min_properties + self["minProperties"] + end + + # @return [Node::Array, nil] + def required + self["required"] + end + + # Returns whether a property is a required field or not. Can accept the + # property name or a schema + # + # @param [String, Schema] property + # @return [Boolean] + def requires?(property) + if property.is_a?(self.class) + # compare node_context of objects to ensure references aren't treated + # as equal - only direct properties of this object will pass. + properties.to_h + .slice(*required.to_a) + .any? { |_, schema| schema.node_context == property.node_context } + else + required.to_a.include?(property) + end + end + + # @return [Node::Array, nil] + def enum + self["enum"] + end + + # @return [String, nil] + def type + self["type"] + end + + # @return [Node::Array, nil] + def all_of + self["allOf"] + end + + # @return [Node::Array, nil] + def one_of + self["oneOf"] + end + + # @return [Node::Array, nil] + def any_of + self["anyOf"] + end + + # @return [Schema, nil] + def not + self["not"] + end + + # @return [Schema, nil] + def items + self["items"] + end + + # @return [Map] + def properties + self["properties"] + end + + # @return [Boolean] + def additional_properties? + self["additionalProperties"] != false + end + + # @return [Schema, nil] + def additional_properties_schema + properties = self["additionalProperties"] + return if [true, false].include?(properties) + + properties + end + + # @return [String, nil] + def description + self["description"] + end + + # @return [String, nil] + def description_html + render_markdown(description) + end + + # @return [String, nil] + def format + self["format"] + end + + # @return [Any] + def default + self["default"] + end + + # @return [Boolean] + def nullable? + self["nullable"] + end + + # @return [Discriminator, nil] + def discriminator + self["discriminator"] + end + + # @return [Boolean] + def read_only? + self["readOnly"] + end + + # @return [Boolean] + def write_only? + self["writeOnly"] + end + + # @return [Xml, nil] + def xml + self["xml"] + end + + # @return [ExternalDocumentation, nil] + def external_docs + self["externalDocs"] + end + + # @return [Any] + def example + self["example"] + end + + # @return [Boolean] + def deprecated? + self["deprecated"] + end + end + # rubocop:enable Metrics/ClassLength + end + end +end diff --git a/lib/openapi3_parser/node_factory/components.rb b/lib/openapi3_parser/node_factory/components.rb index 07f7538f..d1dcde44 100644 --- a/lib/openapi3_parser/node_factory/components.rb +++ b/lib/openapi3_parser/node_factory/components.rb @@ -23,7 +23,11 @@ def build_object(data, context) end def schemas_factory(context) - referenceable_map_factory(context, NodeFactory::Schema) + NodeFactory::Map.new( + context, + value_factory: NodeFactory::Schema.factory(context), + validate: Validation::InputValidator.new(Validators::ComponentKeys) + ) end def responses_factory(context) diff --git a/lib/openapi3_parser/node_factory/media_type.rb b/lib/openapi3_parser/node_factory/media_type.rb index 35affa1a..cfc9f0f3 100644 --- a/lib/openapi3_parser/node_factory/media_type.rb +++ b/lib/openapi3_parser/node_factory/media_type.rb @@ -21,8 +21,7 @@ def build_object(data, context) end def schema_factory(context) - factory = NodeFactory::Schema - NodeFactory::OptionalReference.new(factory).call(context) + NodeFactory::Schema.factory(context).call(context) end def examples_factory(context) diff --git a/lib/openapi3_parser/node_factory/parameter_like.rb b/lib/openapi3_parser/node_factory/parameter_like.rb index fd6349be..ecc806a1 100644 --- a/lib/openapi3_parser/node_factory/parameter_like.rb +++ b/lib/openapi3_parser/node_factory/parameter_like.rb @@ -8,8 +8,7 @@ def default_explode end def schema_factory(context) - factory = NodeFactory::OptionalReference.new(NodeFactory::Schema) - factory.call(context) + NodeFactory::Schema.factory(context).call(context) end def examples_factory(context) diff --git a/lib/openapi3_parser/node_factory/schema.rb b/lib/openapi3_parser/node_factory/schema.rb index d5759999..c8d51088 100644 --- a/lib/openapi3_parser/node_factory/schema.rb +++ b/lib/openapi3_parser/node_factory/schema.rb @@ -1,128 +1,10 @@ # frozen_string_literal: true -require "openapi3_parser/node_factory/object" - module Openapi3Parser module NodeFactory - class Schema < NodeFactory::Object - allow_extensions - field "title", input_type: String - field "multipleOf", input_type: Numeric - field "maximum", input_type: Integer - field "exclusiveMaximum", input_type: :boolean, default: false - field "minimum", input_type: Integer - field "exclusiveMinimum", input_type: :boolean, default: false - field "maxLength", input_type: Integer - field "minLength", input_type: Integer, default: 0 - field "pattern", input_type: String - field "maxItems", input_type: Integer - field "minItems", input_type: Integer, default: 0 - field "uniqueItems", input_type: :boolean, default: false - field "maxProperties", input_type: Integer - field "minProperties", input_type: Integer, default: 0 - field "required", factory: :required_factory - field "enum", factory: :enum_factory - - field "type", input_type: String - field "allOf", factory: :referenceable_schema_array - field "oneOf", factory: :referenceable_schema_array - field "anyOf", factory: :referenceable_schema_array - field "not", factory: :referenceable_schema - field "items", factory: :referenceable_schema - field "properties", factory: :properties_factory - field "additionalProperties", - validate: :additional_properties_input_type, - factory: :additional_properties_factory, - default: false - field "description", input_type: String - field "format", input_type: String - field "default" - - field "nullable", input_type: :boolean, default: false - field "discriminator", factory: :discriminator_factory - field "readOnly", input_type: :boolean, default: false - field "writeOnly", input_type: :boolean, default: false - field "xml", factory: :xml_factory - field "externalDocs", factory: :external_docs_factory - field "example" - field "deprecated", input_type: :boolean, default: false - - validate :items_for_array, :read_only_or_write_only - - private - - def items_for_array(validatable) - return unless validatable.input["type"] == "array" - return unless validatable.factory.resolved_input["items"].nil? - - validatable.add_error("items must be defined for a type of array") - end - - def read_only_or_write_only(validatable) - input = validatable.input - return if [input["readOnly"], input["writeOnly"]].uniq != [true] - - validatable.add_error("readOnly and writeOnly cannot both be true") - end - - def build_object(data, context) - Node::Schema.new(data, context) - end - - def required_factory(context) - NodeFactory::Array.new( - context, - default: nil, - value_input_type: String - ) - end - - def enum_factory(context) - NodeFactory::Array.new(context, default: nil) - end - - def discriminator_factory(context) - NodeFactory::Discriminator.new(context) - end - - def xml_factory(context) - NodeFactory::Xml.new(context) - end - - def external_docs_factory(context) - NodeFactory::ExternalDocumentation.new(context) - end - - def properties_factory(context) - NodeFactory::Map.new( - context, - value_factory: NodeFactory::OptionalReference.new(self.class) - ) - end - - def referenceable_schema(context) - NodeFactory::OptionalReference.new(self.class).call(context) - end - - def referenceable_schema_array(context) - NodeFactory::Array.new( - context, - default: nil, - value_factory: NodeFactory::OptionalReference.new(self.class) - ) - end - - def additional_properties_input_type(validatable) - input = validatable.input - return if [true, false].include?(input) || input.is_a?(Hash) - - validatable.add_error("Expected a Boolean or an Object") - end - - def additional_properties_factory(context) - return context.input if [true, false].include?(context.input) - - referenceable_schema(context) + module Schema + def self.factory(_context) + NodeFactory::OptionalReference.new(V3_0) end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb new file mode 100644 index 00000000..0bd2f233 --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "openapi3_parser/node_factory/object" + +module Openapi3Parser + module NodeFactory + module Schema + class V3_0 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase + allow_extensions + field "title", input_type: String + field "multipleOf", input_type: Numeric + field "maximum", input_type: Integer + field "exclusiveMaximum", input_type: :boolean, default: false + field "minimum", input_type: Integer + field "exclusiveMinimum", input_type: :boolean, default: false + field "maxLength", input_type: Integer + field "minLength", input_type: Integer, default: 0 + field "pattern", input_type: String + field "maxItems", input_type: Integer + field "minItems", input_type: Integer, default: 0 + field "uniqueItems", input_type: :boolean, default: false + field "maxProperties", input_type: Integer + field "minProperties", input_type: Integer, default: 0 + field "required", factory: :required_factory + field "enum", factory: :enum_factory + + field "type", input_type: String + field "allOf", factory: :referenceable_schema_array + field "oneOf", factory: :referenceable_schema_array + field "anyOf", factory: :referenceable_schema_array + field "not", factory: :referenceable_schema + field "items", factory: :referenceable_schema + field "properties", factory: :properties_factory + field "additionalProperties", + validate: :additional_properties_input_type, + factory: :additional_properties_factory, + default: false + field "description", input_type: String + field "format", input_type: String + field "default" + + field "nullable", input_type: :boolean, default: false + field "discriminator", factory: :discriminator_factory + field "readOnly", input_type: :boolean, default: false + field "writeOnly", input_type: :boolean, default: false + field "xml", factory: :xml_factory + field "externalDocs", factory: :external_docs_factory + field "example" + field "deprecated", input_type: :boolean, default: false + + validate :items_for_array, :read_only_or_write_only + + private + + def items_for_array(validatable) + return unless validatable.input["type"] == "array" + return unless validatable.factory.resolved_input["items"].nil? + + validatable.add_error("items must be defined for a type of array") + end + + def read_only_or_write_only(validatable) + input = validatable.input + return if [input["readOnly"], input["writeOnly"]].uniq != [true] + + validatable.add_error("readOnly and writeOnly cannot both be true") + end + + def build_object(data, context) + Node::Schema::V3_0.new(data, context) + end + + def required_factory(context) + NodeFactory::Array.new( + context, + default: nil, + value_input_type: String + ) + end + + def enum_factory(context) + NodeFactory::Array.new(context, default: nil) + end + + def discriminator_factory(context) + NodeFactory::Discriminator.new(context) + end + + def xml_factory(context) + NodeFactory::Xml.new(context) + end + + def external_docs_factory(context) + NodeFactory::ExternalDocumentation.new(context) + end + + def properties_factory(context) + NodeFactory::Map.new( + context, + value_factory: NodeFactory::Schema.factory(context) + ) + end + + def referenceable_schema(context) + NodeFactory::Schema.factory(context).call(context) + end + + def referenceable_schema_array(context) + NodeFactory::Array.new( + context, + default: nil, + value_factory: NodeFactory::Schema.factory(context) + ) + end + + def additional_properties_input_type(validatable) + input = validatable.input + return if [true, false].include?(input) || input.is_a?(Hash) + + validatable.add_error("Expected a Boolean or an Object") + end + + def additional_properties_factory(context) + return context.input if [true, false].include?(context.input) + + referenceable_schema(context) + end + end + end + end +end diff --git a/spec/integration/open_a_document_with_recursive_references_spec.rb b/spec/integration/open_a_document_with_recursive_references_spec.rb index c3db38e0..2964db55 100644 --- a/spec/integration/open_a_document_with_recursive_references_spec.rb +++ b/spec/integration/open_a_document_with_recursive_references_spec.rb @@ -61,7 +61,7 @@ .items .properties["links"] .items - expect(node).to be_a(Openapi3Parser::Node::Schema) + expect(node).to be_a(Openapi3Parser::Node::Schema::V3_0) end it "returns the expected node class for a directly recursive property" do @@ -69,7 +69,7 @@ .schemas["RecursiveItem"] .properties["directly_recursive"] .properties["directly_recursive"] - expect(node).to be_a(Openapi3Parser::Node::Schema) + expect(node).to be_a(Openapi3Parser::Node::Schema::V3_0) end it "returns the expected node class for an indirectly recursive property" do @@ -77,13 +77,13 @@ .schemas["RecursiveItem"] .properties["indirectly_recursive"] .properties["recursive_item"] - expect(node).to be_a(Openapi3Parser::Node::Schema) + expect(node).to be_a(Openapi3Parser::Node::Schema::V3_0) end it "returns the expected node class for a recursive item in an array" do node = document.components .schemas["RecursiveArray"] .one_of[0] - expect(node).to be_a(Openapi3Parser::Node::Schema) + expect(node).to be_a(Openapi3Parser::Node::Schema::V3_0) end end diff --git a/spec/lib/openapi3_parser/node/schema_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb similarity index 94% rename from spec/lib/openapi3_parser/node/schema_spec.rb rename to spec/lib/openapi3_parser/node/schema/v3_0_spec.rb index ac9ead34..9a92b29c 100644 --- a/spec/lib/openapi3_parser/node/schema_spec.rb +++ b/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Openapi3Parser::Node::Schema do +RSpec.describe Openapi3Parser::Node::Schema::V3_0 do describe "#name" do it "returns the key of the context when the item is defined within components/schemas" do node_context = create_node_context( @@ -33,7 +33,7 @@ } factory_context = create_node_factory_context(input) - Openapi3Parser::NodeFactory::Schema + Openapi3Parser::NodeFactory::Schema::V3_0 .new(factory_context) .node(node_factory_context_to_node_context(factory_context)) end @@ -74,7 +74,7 @@ } factory_context = create_node_factory_context(input, document_input:) - Openapi3Parser::NodeFactory::Schema + Openapi3Parser::NodeFactory::Schema::V3_0 .new(factory_context) .node(node_factory_context_to_node_context(factory_context)) end diff --git a/spec/lib/openapi3_parser/node_factory/context_spec.rb b/spec/lib/openapi3_parser/node_factory/context_spec.rb index 99794c4f..defb9363 100644 --- a/spec/lib/openapi3_parser/node_factory/context_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/context_spec.rb @@ -112,7 +112,7 @@ source_location = create_source_location(input) instance = described_class.new({}, source_location:) resolved_reference = instance.resolve_reference("#/components/schemas/item", - Openapi3Parser::NodeFactory::Schema) + Openapi3Parser::NodeFactory::Schema::V3_0) expect(resolved_reference) .to be_a(Openapi3Parser::Source::ResolvedReference) end diff --git a/spec/lib/openapi3_parser/node_factory/schema_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb similarity index 98% rename from spec/lib/openapi3_parser/node_factory/schema_spec.rb rename to spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb index fc08c5ad..c23eb9ad 100644 --- a/spec/lib/openapi3_parser/node_factory/schema_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -RSpec.describe Openapi3Parser::NodeFactory::Schema do - it_behaves_like "node object factory", Openapi3Parser::Node::Schema do +RSpec.describe Openapi3Parser::NodeFactory::Schema::V3_0 do + it_behaves_like "node object factory", Openapi3Parser::Node::Schema::V3_0 do let(:input) do { "allOf" => [ From 734b0059ae9b0c0f99b41dd6df2662657e480cd7 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 20 Mar 2022 12:30:53 +0000 Subject: [PATCH 18/53] Create an object for the OAS Dialect 3.1 schema This is an object that is to represent the schema used in https://spec.openapis.org/oas/3.1/dialect/base --- lib/openapi3_parser/node_factory/media_type.rb | 2 +- .../node_factory/parameter_like.rb | 2 +- lib/openapi3_parser/node_factory/schema.rb | 18 ++++++++++++++++-- .../node_factory/schema/oas_dialect_3_1.rb | 12 ++++++++++++ .../node_factory/schema/v3_0.rb | 2 +- spec/integration/open_v3.1_examples_spec.rb | 2 +- 6 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb diff --git a/lib/openapi3_parser/node_factory/media_type.rb b/lib/openapi3_parser/node_factory/media_type.rb index cfc9f0f3..1f3e1c26 100644 --- a/lib/openapi3_parser/node_factory/media_type.rb +++ b/lib/openapi3_parser/node_factory/media_type.rb @@ -21,7 +21,7 @@ def build_object(data, context) end def schema_factory(context) - NodeFactory::Schema.factory(context).call(context) + NodeFactory::Schema.build_factory(context) end def examples_factory(context) diff --git a/lib/openapi3_parser/node_factory/parameter_like.rb b/lib/openapi3_parser/node_factory/parameter_like.rb index ecc806a1..a6b8425d 100644 --- a/lib/openapi3_parser/node_factory/parameter_like.rb +++ b/lib/openapi3_parser/node_factory/parameter_like.rb @@ -8,7 +8,7 @@ def default_explode end def schema_factory(context) - NodeFactory::Schema.factory(context).call(context) + NodeFactory::Schema.build_factory(context) end def examples_factory(context) diff --git a/lib/openapi3_parser/node_factory/schema.rb b/lib/openapi3_parser/node_factory/schema.rb index c8d51088..0c4f66d6 100644 --- a/lib/openapi3_parser/node_factory/schema.rb +++ b/lib/openapi3_parser/node_factory/schema.rb @@ -3,8 +3,22 @@ module Openapi3Parser module NodeFactory module Schema - def self.factory(_context) - NodeFactory::OptionalReference.new(V3_0) + def self.factory(context) + if context.openapi_version >= "3.1" + OasDialect3_1 + else + NodeFactory::OptionalReference.new(V3_0) + end + end + + def self.build_factory(context) + fetched_factory = factory(context) + + if fetched_factory.is_a?(Class) + fetched_factory.new(context) + else + fetched_factory.call(context) + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb b/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb new file mode 100644 index 00000000..1c0342f9 --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "openapi3_parser/node_factory/object" + +module Openapi3Parser + module NodeFactory + module Schema + class OasDialect3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb index 0bd2f233..5a07a904 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_0.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -102,7 +102,7 @@ def properties_factory(context) end def referenceable_schema(context) - NodeFactory::Schema.factory(context).call(context) + NodeFactory::Schema.build_factory(context) end def referenceable_schema_array(context) diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb index bf8127c7..9e9519d9 100644 --- a/spec/integration/open_v3.1_examples_spec.rb +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -12,7 +12,7 @@ context "when using the webhook example" do let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "webhook-example.yaml") } - it "is a valid document" do + xit "is a valid document" do expect(document).to be_valid end From 08e1a5be532383ef2e6bdcb51c4afa8747955894 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 20 Mar 2022 13:17:10 +0000 Subject: [PATCH 19/53] Make extension regex configurable This has been provided so that the OpenAPI 3.1 Schema can accept a schema with extensions without the `x-` prefix that OpenAPI nodes require. --- lib/openapi3_parser/node_factory/object.rb | 2 +- .../node_factory/object_factory/dsl.rb | 12 ++++-------- .../node_factory/object_factory/validator.rb | 2 +- .../node_factory/schema/oas_dialect_3_1.rb | 3 +++ .../validators/unexpected_fields.rb | 10 +++++----- spec/integration/open_v3.1_examples_spec.rb | 2 +- .../validators/unexpected_fields_spec.rb | 17 ++++++++--------- 7 files changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/openapi3_parser/node_factory/object.rb b/lib/openapi3_parser/node_factory/object.rb index c8176f54..5d16be9a 100644 --- a/lib/openapi3_parser/node_factory/object.rb +++ b/lib/openapi3_parser/node_factory/object.rb @@ -11,7 +11,7 @@ class Object def_delegators "self.class", :field_configs, - :allowed_extensions?, + :extension_regex, :mutually_exclusive_fields, :allowed_default?, :validations diff --git a/lib/openapi3_parser/node_factory/object_factory/dsl.rb b/lib/openapi3_parser/node_factory/object_factory/dsl.rb index 5a86f2a9..bb7b39fa 100644 --- a/lib/openapi3_parser/node_factory/object_factory/dsl.rb +++ b/lib/openapi3_parser/node_factory/object_factory/dsl.rb @@ -17,16 +17,12 @@ def field_configs @field_configs ||= {} end - def allow_extensions - @allow_extensions = true + def allow_extensions(regex: EXTENSION_REGEX) + @extension_regex = regex end - def allowed_extensions? - if instance_variable_defined?(:@allow_extensions) - @allow_extensions == true - else - false - end + def extension_regex + @extension_regex ||= nil end def mutually_exclusive(*fields, required: false) diff --git a/lib/openapi3_parser/node_factory/object_factory/validator.rb b/lib/openapi3_parser/node_factory/object_factory/validator.rb index bae090bb..8e0533ca 100644 --- a/lib/openapi3_parser/node_factory/object_factory/validator.rb +++ b/lib/openapi3_parser/node_factory/object_factory/validator.rb @@ -41,7 +41,7 @@ def check_required_fields def check_unexpected_fields Validators::UnexpectedFields.call( validatable, - allow_extensions: factory.allowed_extensions?, + extension_regex: factory.extension_regex, allowed_fields: factory.allowed_fields, raise_on_invalid: ) diff --git a/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb b/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb index 1c0342f9..cd6ed99e 100644 --- a/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb @@ -6,6 +6,9 @@ module Openapi3Parser module NodeFactory module Schema class OasDialect3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase + # Allows any extension as per: + # https://github.com/OAI/OpenAPI-Specification/blob/a1facce1b3621df3630cb692e9fbe18a7612ea6d/versions/3.1.0.md#fixed-fields-20 + allow_extensions(regex: /.*/) end end end diff --git a/lib/openapi3_parser/validators/unexpected_fields.rb b/lib/openapi3_parser/validators/unexpected_fields.rb index ff28d10a..501cd1c8 100644 --- a/lib/openapi3_parser/validators/unexpected_fields.rb +++ b/lib/openapi3_parser/validators/unexpected_fields.rb @@ -14,11 +14,11 @@ def self.call(*args, **kwargs) def call(validatable, allowed_fields:, - allow_extensions: true, + extension_regex: nil, raise_on_invalid: true) fields = unexpected_fields(validatable.input, allowed_fields, - allow_extensions) + extension_regex) return if fields.empty? if raise_on_invalid @@ -35,11 +35,11 @@ def call(validatable, private - def unexpected_fields(input, allowed_fields, allow_extensions) + def unexpected_fields(input, allowed_fields, extension_regex) extra_keys = input.keys - allowed_fields - return extra_keys unless allow_extensions + return extra_keys unless extension_regex - extra_keys.grep_v(NodeFactory::EXTENSION_REGEX) + extra_keys.grep_v(extension_regex) end end end diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb index 9e9519d9..bf8127c7 100644 --- a/spec/integration/open_v3.1_examples_spec.rb +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -12,7 +12,7 @@ context "when using the webhook example" do let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "webhook-example.yaml") } - xit "is a valid document" do + it "is a valid document" do expect(document).to be_valid end diff --git a/spec/lib/openapi3_parser/validators/unexpected_fields_spec.rb b/spec/lib/openapi3_parser/validators/unexpected_fields_spec.rb index aac8d054..a981c76b 100644 --- a/spec/lib/openapi3_parser/validators/unexpected_fields_spec.rb +++ b/spec/lib/openapi3_parser/validators/unexpected_fields_spec.rb @@ -27,23 +27,22 @@ end end - describe "allow_extensions option" do + describe "extension_regex option" do let(:validatable) do create_validatable({ "x-extension" => "my extension", "x-extension-2" => "my other extension" }) end - it "defaults to allowing extensions" do + it "defaults to disallowing extensions" do + validatable = create_validatable({ "extension" => "my extension" }) expect { described_class.call(validatable, allowed_fields: []) } - .not_to raise_error + .to raise_error(Openapi3Parser::Error::UnexpectedFields, "Unexpected fields for #/: extension") end - it "raises an error when allow_extensions is false" do - expect { described_class.call(validatable, allowed_fields: [], allow_extensions: false) } - .to raise_error( - Openapi3Parser::Error::UnexpectedFields, - "Unexpected fields for #/: x-extension and x-extension-2" - ) + it "accepts a regex of the pattern of extension that will be accepted" do + validatable = create_validatable({ "x-extension" => "my extension" }) + expect { described_class.call(validatable, allowed_fields: [], extension_regex: /^x-.*/) } + .not_to raise_error end end From 24b42649dfd7228cb7ced97c7111a3c62d91cc5b Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 10 Apr 2022 19:46:21 +0100 Subject: [PATCH 20/53] Refactor reference resolution for OpenAPI 3.1 Sorry - this is way too big for a single commit. This changes the approach for handling references to be one where data can be merged between a chain of references. Previously the approach was to only allow a single $ref field (as per OpenAPI 3.0) The reason for making this change is based on aspects in the specification notably the reference object [1] that allows title and summary fields to be overridden through the referencing process. As far as I could tell schemas also share this property as per the newer JSON Schema specifications (most applicable one for OpenAPI 3.1 is 2020-12) though I found it quite hard to find a clear source on behaviour [2]. To support this there are now some new concepts: - A node context now has multiple source_locations - as data can be combined from multiple places - A node context has a concept, input_locations, which record all of the data involved in a node that aren't purely references (i.e. all the source locations that contributed to the node data) - There is now a Referenceable module that can be mixed into NodeFactory classes, this allows NodeFactories to act more as the mediator in charge of a reference, whereas previously more went through a Reference field (this is to reflect the nature of merging) - Aspects of the ObjectFactory::NodeBuilder class have been separated into ObjectFactory::NodeErrors, ObjectFactory::ResolvedInputBuilder and a new iteration of it's namesake. This is to reflect an increase in complexity in node building due to the node data merging - As part of the new NodeBuilder class the building of node objects is now done by the NodeBuilder class calling public methods on node factories. This has led to them having their #build_object methods replaced with #build_node methods that need a public interface [1]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#referenceObject [2]: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-8.2.3.1 --- lib/openapi3_parser/node/array.rb | 2 +- lib/openapi3_parser/node/context.rb | 90 +++-- lib/openapi3_parser/node/map.rb | 2 +- lib/openapi3_parser/node/object.rb | 2 +- .../node/schema/oas_dialect3_1.rb | 12 + lib/openapi3_parser/node/schema/v3_0.rb | 2 +- lib/openapi3_parser/node_factory/array.rb | 16 +- lib/openapi3_parser/node_factory/callback.rb | 2 - .../node_factory/components.rb | 8 +- lib/openapi3_parser/node_factory/contact.rb | 6 +- .../node_factory/discriminator.rb | 8 +- lib/openapi3_parser/node_factory/encoding.rb | 8 +- lib/openapi3_parser/node_factory/example.rb | 6 +- .../node_factory/external_documentation.rb | 6 +- lib/openapi3_parser/node_factory/field.rb | 56 +-- .../node_factory/fields/reference.rb | 46 +-- lib/openapi3_parser/node_factory/header.rb | 6 +- lib/openapi3_parser/node_factory/info.rb | 6 +- lib/openapi3_parser/node_factory/license.rb | 6 +- lib/openapi3_parser/node_factory/link.rb | 8 +- lib/openapi3_parser/node_factory/map.rb | 12 +- .../node_factory/media_type.rb | 8 +- .../node_factory/oauth_flow.rb | 6 +- .../node_factory/oauth_flows.rb | 8 +- lib/openapi3_parser/node_factory/object.rb | 30 +- .../object_factory/node_builder.rb | 104 ++++-- .../object_factory/node_errors.rb | 27 ++ .../object_factory/resolved_input_builder.rb | 64 ++++ lib/openapi3_parser/node_factory/openapi.rb | 8 +- lib/openapi3_parser/node_factory/operation.rb | 10 +- lib/openapi3_parser/node_factory/parameter.rb | 8 +- lib/openapi3_parser/node_factory/path_item.rb | 46 +-- lib/openapi3_parser/node_factory/paths.rb | 4 +- .../node_factory/recursive_resolved_input.rb | 23 ++ lib/openapi3_parser/node_factory/reference.rb | 39 +- .../node_factory/referenceable.rb | 58 +++ .../node_factory/request_body.rb | 8 +- lib/openapi3_parser/node_factory/response.rb | 8 +- lib/openapi3_parser/node_factory/responses.rb | 4 +- .../node_factory/schema/oas_dialect3_1.rb | 37 ++ .../node_factory/schema/oas_dialect_3_1.rb | 15 - .../node_factory/schema/v3_0.rb | 8 +- .../node_factory/security_requirement.rb | 2 - .../node_factory/security_scheme.rb | 6 +- lib/openapi3_parser/node_factory/server.rb | 8 +- .../node_factory/server_variable.rb | 8 +- lib/openapi3_parser/node_factory/tag.rb | 6 +- lib/openapi3_parser/node_factory/xml.rb | 6 +- .../source/resolved_reference.rb | 1 - spec/integration/open_v3.1_examples_spec.rb | 11 +- spec/lib/openapi3_parser/node/context_spec.rb | 199 +++++++--- .../node_factory/fields/reference_spec.rb | 49 +-- .../object_factory/node_builder_spec.rb | 347 ++++++++++++++---- .../object_factory/node_errors_spec.rb | 83 +++++ .../resolved_input_builder_spec.rb | 124 +++++++ .../node_factory/path_item_spec.rb | 13 +- .../node_factory/reference_spec.rb | 13 +- .../schema/oas_dialect3_1_spec.rb | 50 +++ spec/support/helpers/context.rb | 23 +- spec/support/node_equality.rb | 13 +- 60 files changed, 1247 insertions(+), 548 deletions(-) create mode 100644 lib/openapi3_parser/node/schema/oas_dialect3_1.rb create mode 100644 lib/openapi3_parser/node_factory/object_factory/node_errors.rb create mode 100644 lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb create mode 100644 lib/openapi3_parser/node_factory/recursive_resolved_input.rb create mode 100644 lib/openapi3_parser/node_factory/referenceable.rb create mode 100644 lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb delete mode 100644 lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb create mode 100644 spec/lib/openapi3_parser/node_factory/object_factory/node_errors_spec.rb create mode 100644 spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb create mode 100644 spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb diff --git a/lib/openapi3_parser/node/array.rb b/lib/openapi3_parser/node/array.rb index c558de89..4b3bfd4a 100644 --- a/lib/openapi3_parser/node/array.rb +++ b/lib/openapi3_parser/node/array.rb @@ -40,7 +40,7 @@ def each(&) # @return [Boolean] def ==(other) other.instance_of?(self.class) && - node_context.same_data_and_source?(other.node_context) + node_context.same_data_inputs?(other.node_context) end # Used to access a node relative to this node diff --git a/lib/openapi3_parser/node/context.rb b/lib/openapi3_parser/node/context.rb index 9eee66a6..c1b87ea1 100644 --- a/lib/openapi3_parser/node/context.rb +++ b/lib/openapi3_parser/node/context.rb @@ -20,10 +20,15 @@ class Context # @param [NodeFactory::Context] factory_context # @return [Node::Context] def self.root(factory_context) - location = Source::Location.new(factory_context.source, []) + document_location = Source::Location.new(factory_context.source, []) + + source_location = factory_context.source_location + input_locations = input_location?(factory_context.input) ? [source_location] : [] + new(factory_context.input, - document_location: location, - source_location: factory_context.source_location) + document_location: document_location, + source_locations: [source_location], + input_locations: input_locations) end # Create a context for the child of a previous context @@ -38,9 +43,16 @@ def self.next_field(parent_context, field, factory_context) field ) + input_locations = if input_location?(factory_context.input) + [factory_context.source_location] + else + [] + end + new(factory_context.input, document_location:, - source_location: factory_context.source_location) + source_locations: [factory_context.source_location], + input_locations: input_locations) end # Create a context for a the a field that is the result of a reference @@ -49,36 +61,63 @@ def self.next_field(parent_context, field, factory_context) # @param [NodeFactory::Context] reference_factory_context # @return [Node::Context] def self.resolved_reference(current_context, reference_factory_context) - new(reference_factory_context.input, + input_locations = if input_location?(reference_factory_context.input) + current_context.input_locations + [reference_factory_context.source_location] + else + current_context.input_locations + end + + input = merge_reference_input(current_context.input, reference_factory_context.input) + new(input, document_location: current_context.document_location, - source_location: reference_factory_context.source_location) + source_locations: current_context.source_locations + [reference_factory_context.source_location], + input_locations: input_locations) + end + + def self.merge_reference_input(current_input, reference_input) + can_merge = reference_input.respond_to?(:merge) && current_input.respond_to?(:merge) + + return reference_input unless can_merge + + input = reference_input.merge(current_input) + input.delete("$ref") + input end - attr_reader :input, :document_location, :source_location + def self.input_location?(input) + return true unless input.respond_to?(:keys) - # @param input - # @param [Source::Location] document_location - # @param [Source::Location] source_location - def initialize(input, document_location:, source_location:) + input.keys != ["$ref"] + end + + attr_reader :input, :document_location, :source_locations, :input_locations + + # @param input + # @param [Source::Location] document_location + # @param [Array] source_locations + # @param [Array] input_locations + def initialize(input, document_location:, source_locations:, input_locations:) @input = input @document_location = document_location - @source_location = source_location + @source_locations = source_locations + @input_locations = input_locations end # @param [Context] other # @return [Boolean] def ==(other) document_location == other.document_location && - same_data_and_source?(other) + source_locations == other.source_locations && + same_data_inputs?(other) end # Check that contexts are the same without concern for document location # # @param [Context] other # @return [Boolean] - def same_data_and_source?(other) + def same_data_inputs?(other) input == other.input && - source_location == other.source_location + input_locations == other.input_locations end # The OpenAPI document associated with this context @@ -88,17 +127,24 @@ def document document_location.source.document end - # The source file used to provide the data for this node + # The source files used to provide the data for this node + # + # @return [Array] + def sources + [source_locations].map(&:source) + end + + # The source files used to provide the input for this node # - # @return [Source] - def source - source_location.source + # @return [Array] + def input_sources + [input_locations].map(&:source) end # @return [String] def inspect %{#{self.class.name}(document_location: #{document_location}, } + - %{source_location: #{source_location})} + %{input_locations: #{input_locations.join(', ')})} end # A string representing the location of the node @@ -107,7 +153,9 @@ def inspect def location_summary summary = document_location.to_s - summary += " (#{source_location})" if document_location != source_location + if input_locations.length > 1 || document_location != input_locations.first + summary += " (#{input_locations.join(', ')})" + end summary end diff --git a/lib/openapi3_parser/node/map.rb b/lib/openapi3_parser/node/map.rb index 023f51ad..2d7a64be 100644 --- a/lib/openapi3_parser/node/map.rb +++ b/lib/openapi3_parser/node/map.rb @@ -53,7 +53,7 @@ def extension(value) # @return [Boolean] def ==(other) other.instance_of?(self.class) && - node_context.same_data_and_source?(other.node_context) + node_context.same_data_inputs?(other.node_context) end # Iterates through the data of this node, used by Enumerable diff --git a/lib/openapi3_parser/node/object.rb b/lib/openapi3_parser/node/object.rb index 0c5bc86c..21b77156 100644 --- a/lib/openapi3_parser/node/object.rb +++ b/lib/openapi3_parser/node/object.rb @@ -53,7 +53,7 @@ def extension(value) # @return [Boolean] def ==(other) other.instance_of?(self.class) && - node_context.same_data_and_source?(other.node_context) + node_context.same_data_inputs?(other.node_context) end # Iterates through the data of this node, used by Enumerable diff --git a/lib/openapi3_parser/node/schema/oas_dialect3_1.rb b/lib/openapi3_parser/node/schema/oas_dialect3_1.rb new file mode 100644 index 00000000..19b8f7fa --- /dev/null +++ b/lib/openapi3_parser/node/schema/oas_dialect3_1.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "openapi3_parser/node/object" + +module Openapi3Parser + module Node + module Schema + class OasDialect3_1 < Node::Object # rubocop:disable Naming/ClassAndModuleCamelCase + end + end + end +end diff --git a/lib/openapi3_parser/node/schema/v3_0.rb b/lib/openapi3_parser/node/schema/v3_0.rb index 6d8bafd4..79a71f23 100644 --- a/lib/openapi3_parser/node/schema/v3_0.rb +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -35,7 +35,7 @@ class V3_0 < Node::Object # rubocop:disable Naming/ClassAndModuleCamelCase # # @return [String, nil] def name - segments = node_context.source_location.pointer.segments + segments = node_context.source_locations.first.pointer.segments segments[-1] if segments[-2] == "schemas" end diff --git a/lib/openapi3_parser/node_factory/array.rb b/lib/openapi3_parser/node_factory/array.rb index c644ac3b..5b7b9cab 100644 --- a/lib/openapi3_parser/node_factory/array.rb +++ b/lib/openapi3_parser/node_factory/array.rb @@ -61,6 +61,10 @@ def use_default? raw_input.empty? end + def build_node(data, node_context) + Node::Array.new(data, node_context) if data + end + private def build_data(raw_input) @@ -87,15 +91,17 @@ def initialize_value_factory(field_context) end end - def build_node(data, node_context) - Node::Array.new(data, node_context) if data - end - def build_resolved_input return unless data data.map do |value| - value.respond_to?(:resolved_input) ? value.resolved_input : value + if value.respond_to?(:in_recursive_loop?) && value.in_recursive_loop? + RecursiveResolvedInput.new(value) + elsif value.respond_to?(:resolved_input) + value.resolved_input + else + value + end end end diff --git a/lib/openapi3_parser/node_factory/callback.rb b/lib/openapi3_parser/node_factory/callback.rb index 0234c766..fa3b6b8e 100644 --- a/lib/openapi3_parser/node_factory/callback.rb +++ b/lib/openapi3_parser/node_factory/callback.rb @@ -11,8 +11,6 @@ def initialize(context) value_factory: NodeFactory::PathItem) end - private - def build_node(data, node_context) Node::Callback.new(data, node_context) end diff --git a/lib/openapi3_parser/node_factory/components.rb b/lib/openapi3_parser/node_factory/components.rb index d1dcde44..4c723345 100644 --- a/lib/openapi3_parser/node_factory/components.rb +++ b/lib/openapi3_parser/node_factory/components.rb @@ -16,12 +16,12 @@ class Components < NodeFactory::Object field "links", factory: :links_factory field "callbacks", factory: :callbacks_factory - private - - def build_object(data, context) - Node::Components.new(data, context) + def build_node(data, node_context) + Node::Components.new(data, node_context) end + private + def schemas_factory(context) NodeFactory::Map.new( context, diff --git a/lib/openapi3_parser/node_factory/contact.rb b/lib/openapi3_parser/node_factory/contact.rb index 7c1a34f0..a5fd6b77 100644 --- a/lib/openapi3_parser/node_factory/contact.rb +++ b/lib/openapi3_parser/node_factory/contact.rb @@ -18,10 +18,8 @@ class Contact < NodeFactory::Object input_type: String, validate: Validation::InputValidator.new(Validators::Email) - private - - def build_object(data, context) - Node::Contact.new(data, context) + def build_node(data, node_context) + Node::Contact.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/discriminator.rb b/lib/openapi3_parser/node_factory/discriminator.rb index 8d9a953c..8df0d59e 100644 --- a/lib/openapi3_parser/node_factory/discriminator.rb +++ b/lib/openapi3_parser/node_factory/discriminator.rb @@ -10,12 +10,12 @@ class Discriminator < NodeFactory::Object validate: :validate_mapping, default: -> { {}.freeze } - private - - def build_object(data, context) - Node::Discriminator.new(data, context) + def build_node(data, node_context) + Node::Discriminator.new(data, node_context) end + private + def validate_mapping(validatable) input = validatable.input return if input.empty? diff --git a/lib/openapi3_parser/node_factory/encoding.rb b/lib/openapi3_parser/node_factory/encoding.rb index d6d26518..b095019d 100644 --- a/lib/openapi3_parser/node_factory/encoding.rb +++ b/lib/openapi3_parser/node_factory/encoding.rb @@ -13,12 +13,12 @@ class Encoding < NodeFactory::Object field "explode", input_type: :boolean, default: :default_explode field "allowReserved", input_type: :boolean, default: false - private - - def build_object(data, context) - Node::Encoding.new(data, context) + def build_node(data, node_context) + Node::Encoding.new(data, node_context) end + private + def headers_factory(context) factory = NodeFactory::OptionalReference.new(NodeFactory::Header) NodeFactory::Map.new(context, value_factory: factory) diff --git a/lib/openapi3_parser/node_factory/example.rb b/lib/openapi3_parser/node_factory/example.rb index bd7449e1..c711b932 100644 --- a/lib/openapi3_parser/node_factory/example.rb +++ b/lib/openapi3_parser/node_factory/example.rb @@ -18,10 +18,8 @@ class Example < NodeFactory::Object mutually_exclusive "value", "externalValue" - private - - def build_object(data, context) - Node::Example.new(data, context) + def build_node(data, node_context) + Node::Example.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/external_documentation.rb b/lib/openapi3_parser/node_factory/external_documentation.rb index 2418dbb1..ac921f75 100644 --- a/lib/openapi3_parser/node_factory/external_documentation.rb +++ b/lib/openapi3_parser/node_factory/external_documentation.rb @@ -15,10 +15,8 @@ class ExternalDocumentation < NodeFactory::Object input_type: String, validate: Validation::InputValidator.new(Validators::Url) - private - - def build_object(data, context) - Node::ExternalDocumentation.new(data, context) + def build_node(data, node_context) + Node::ExternalDocumentation.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/field.rb b/lib/openapi3_parser/node_factory/field.rb index 65c4b79c..de8640cd 100644 --- a/lib/openapi3_parser/node_factory/field.rb +++ b/lib/openapi3_parser/node_factory/field.rb @@ -36,72 +36,56 @@ def default end def errors - @errors ||= ValidNodeBuilder.errors(self) + @errors ||= Validator.call(self) end def node(node_context) - data = ValidNodeBuilder.data(self) - data.nil? ? nil : build_node(data, node_context) + Validator.call(self, raise_on_invalid: true) + data_to_use = nil_input? && default.nil? ? nil : data + data_to_use.nil? ? nil : build_node(data, node_context) end def inspect %{#{self.class.name}(#{context.source_location.inspect})} end - private - def build_node(data, _node_context) data end - class ValidNodeBuilder - def self.errors(factory) - new(factory).errors - end + class Validator + private_class_method :new - def self.data(factory) - new(factory).data + def self.call(*args, **kwargs) + new(*args, **kwargs).call end - def initialize(factory) + def initialize(factory, raise_on_invalid: false) @factory = factory + @raise_on_invalid = raise_on_invalid @validatable = Validation::Validatable.new(factory) end - def errors + def call return validatable.collection if factory.nil_input? - TypeChecker.validate_type(validatable, type: factory.input_type) + if raise_on_invalid + TypeChecker.raise_on_invalid_type(factory.context, type: factory.input_type) + else + TypeChecker.validate_type(validatable, type: factory.input_type) + end + return validatable.collection if validatable.errors.any? - validate(raise_on_invalid: false) + validate validatable.collection end - def data - return default_value if factory.nil_input? - - TypeChecker.raise_on_invalid_type(factory.context, - type: factory.input_type) - validate(raise_on_invalid: true) - factory.data - end - - private_class_method :new - private - attr_reader :factory, :validatable - - def default_value - if factory.nil_input? && factory.default.nil? - nil - else - factory.data - end - end + attr_reader :factory, :validatable, :raise_on_invalid - def validate(raise_on_invalid: false) + def validate run_validation return if !raise_on_invalid || validatable.errors.empty? diff --git a/lib/openapi3_parser/node_factory/fields/reference.rb b/lib/openapi3_parser/node_factory/fields/reference.rb index 250d41b9..a1c32156 100644 --- a/lib/openapi3_parser/node_factory/fields/reference.rb +++ b/lib/openapi3_parser/node_factory/fields/reference.rb @@ -19,37 +19,21 @@ def initialize(context, factory) end def resolved_input - return unless resolved_reference - - if context.self_referencing? - RecursiveResolvedInput.new(resolved_reference.factory) - else - resolved_reference.resolved_input - end + raise Openapi3Parser::Error, "References can't have a resolved input" end def referenced_factory resolved_reference&.factory end + def node(_node_context) + raise Openapi3Parser::Error, "Reference fields can't be built as a node" + end + private attr_reader :reference, :factory, :resolved_reference - def build_node(_data, node_context) - if resolved_reference.nil? - # this shouldn't happen unless dependant code changes - raise Openapi3Parser::Error, - "can't build node without a resolved reference" - end - - reference_context = Node::Context.resolved_reference( - node_context, resolved_reference.factory.context - ) - - resolved_reference.node(reference_context) - end - def validate(validatable) if !reference_validator.valid? validatable.add_errors(reference_validator.errors) @@ -61,7 +45,7 @@ def validate(validatable) end def reference_resolves? - return true unless referenced_factory.is_a?(NodeFactory::Reference) + return true unless referenced_factory.respond_to?(:resolves?) referenced_factory.resolves? end @@ -77,24 +61,6 @@ def create_resolved_reference factory, recursive: context.self_referencing?) end - - # Used in the place of a hash for resolved input so the value can - # be looked up at runtime avoiding a recursive loop. - class RecursiveResolvedInput - extend Forwardable - include Enumerable - - def_delegators :value, :each, :[], :keys - attr_reader :factory - - def initialize(factory) - @factory = factory - end - - def value - @factory.resolved_input - end - end end end end diff --git a/lib/openapi3_parser/node_factory/header.rb b/lib/openapi3_parser/node_factory/header.rb index efbde1f6..1896c031 100644 --- a/lib/openapi3_parser/node_factory/header.rb +++ b/lib/openapi3_parser/node_factory/header.rb @@ -24,10 +24,8 @@ class Header < NodeFactory::Object field "content", factory: :content_factory - private - - def build_object(data, context) - Node::Header.new(data, context) + def build_node(data, node_context) + Node::Header.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/info.rb b/lib/openapi3_parser/node_factory/info.rb index c8d26ff2..ea369786 100644 --- a/lib/openapi3_parser/node_factory/info.rb +++ b/lib/openapi3_parser/node_factory/info.rb @@ -22,10 +22,8 @@ class Info < NodeFactory::Object field "license", factory: NodeFactory::License field "version", input_type: String, required: true - private - - def build_object(data, context) - Node::Info.new(data, context) + def build_node(data, node_context) + Node::Info.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/license.rb b/lib/openapi3_parser/node_factory/license.rb index 818bef8c..bcfc63cc 100644 --- a/lib/openapi3_parser/node_factory/license.rb +++ b/lib/openapi3_parser/node_factory/license.rb @@ -13,10 +13,8 @@ class License < NodeFactory::Object input_type: String, validate: Validation::InputValidator.new(Validators::Url) - private - - def build_object(data, context) - Node::License.new(data, context) + def build_node(data, node_context) + Node::License.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/link.rb b/lib/openapi3_parser/node_factory/link.rb index abf6bcd1..22e84d0c 100644 --- a/lib/openapi3_parser/node_factory/link.rb +++ b/lib/openapi3_parser/node_factory/link.rb @@ -19,12 +19,12 @@ class Link < NodeFactory::Object mutually_exclusive "operationRef", "operationId", required: true - private - - def build_object(data, context) - Node::Link.new(data, context) + def build_node(data, node_context) + Node::Link.new(data, node_context) end + private + def parameters_factory(context) NodeFactory::Map.new(context) end diff --git a/lib/openapi3_parser/node_factory/map.rb b/lib/openapi3_parser/node_factory/map.rb index b458ee37..1e14feb9 100644 --- a/lib/openapi3_parser/node_factory/map.rb +++ b/lib/openapi3_parser/node_factory/map.rb @@ -55,6 +55,10 @@ def inspect %{#{self.class.name}(#{context.source_location.inspect})} end + def build_node(data, node_context) + Node::Map.new(data, node_context) if data + end + private def build_data(raw_input) @@ -83,15 +87,13 @@ def initialize_value_factory(field_context) end end - def build_node(data, node_context) - Node::Map.new(data, node_context) if data - end - def build_resolved_input return unless data data.transform_values do |value| - if value.respond_to?(:resolved_input) + if value.respond_to?(:in_recursive_loop?) && value.in_recursive_loop? + RecursiveResolvedInput.new(value) + elsif value.respond_to?(:resolved_input) value.resolved_input else value diff --git a/lib/openapi3_parser/node_factory/media_type.rb b/lib/openapi3_parser/node_factory/media_type.rb index 1f3e1c26..495b1f8d 100644 --- a/lib/openapi3_parser/node_factory/media_type.rb +++ b/lib/openapi3_parser/node_factory/media_type.rb @@ -14,12 +14,12 @@ class MediaType < NodeFactory::Object mutually_exclusive "example", "examples" - private - - def build_object(data, context) - Node::MediaType.new(data, context) + def build_node(data, node_context) + Node::MediaType.new(data, node_context) end + private + def schema_factory(context) NodeFactory::Schema.build_factory(context) end diff --git a/lib/openapi3_parser/node_factory/oauth_flow.rb b/lib/openapi3_parser/node_factory/oauth_flow.rb index 5b21c5db..2ad26f33 100644 --- a/lib/openapi3_parser/node_factory/oauth_flow.rb +++ b/lib/openapi3_parser/node_factory/oauth_flow.rb @@ -11,10 +11,8 @@ class OauthFlow < NodeFactory::Object field "refreshUrl", input_type: String field "scopes", input_type: Hash - private - - def build_object(data, context) - Node::OauthFlow.new(data, context) + def build_node(data, node_context) + Node::OauthFlow.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/oauth_flows.rb b/lib/openapi3_parser/node_factory/oauth_flows.rb index c8663f28..7815fcc3 100644 --- a/lib/openapi3_parser/node_factory/oauth_flows.rb +++ b/lib/openapi3_parser/node_factory/oauth_flows.rb @@ -11,16 +11,16 @@ class OauthFlows < NodeFactory::Object field "clientCredentials", factory: :oauth_flow_factory field "authorizationCode", factory: :oauth_flow_factory + def build_node(data, node_context) + Node::OauthFlows.new(data, node_context) + end + private def oauth_flow_factory(context) NodeFactory::OptionalReference.new(NodeFactory::OauthFlow) .call(context) end - - def build_object(data, context) - Node::OauthFlows.new(data, context) - end end end end diff --git a/lib/openapi3_parser/node_factory/object.rb b/lib/openapi3_parser/node_factory/object.rb index 5d16be9a..514808f3 100644 --- a/lib/openapi3_parser/node_factory/object.rb +++ b/lib/openapi3_parser/node_factory/object.rb @@ -28,7 +28,7 @@ def initialize(context) end def resolved_input - @resolved_input ||= build_resolved_input + @resolved_input ||= ObjectFactory::ResolvedInputBuilder.call(self) end def raw_input @@ -44,11 +44,16 @@ def valid? end def errors - @errors ||= ObjectFactory::NodeBuilder.errors(self) + @errors ||= ObjectFactory::NodeErrors.call(self) end def node(node_context) - build_node(node_context) + node_builder = ObjectFactory::NodeBuilder.new(self, node_context) + node_builder.build_node + end + + def build_node(_data, _node_context) + raise Error, "Expected to be implemented in child class" end def can_use_default? @@ -95,25 +100,6 @@ def process_data(raw_data) memo[field] = config.initialize_factory(next_context, self) end end - - def build_resolved_input - return unless data - - data.each_with_object({}) do |(key, value), memo| - next if value.respond_to?(:nil_input?) && value.nil_input? - - memo[key] = if value.respond_to?(:resolved_input) - value.resolved_input - else - value - end - end - end - - def build_node(node_context) - data = ObjectFactory::NodeBuilder.node_data(self, node_context) - build_object(data, node_context) if data - end end end end diff --git a/lib/openapi3_parser/node_factory/object_factory/node_builder.rb b/lib/openapi3_parser/node_factory/object_factory/node_builder.rb index 2878fb14..ee1a7197 100644 --- a/lib/openapi3_parser/node_factory/object_factory/node_builder.rb +++ b/lib/openapi3_parser/node_factory/object_factory/node_builder.rb @@ -4,75 +4,105 @@ module Openapi3Parser module NodeFactory module ObjectFactory class NodeBuilder - def self.errors(factory) - new(factory).errors + def initialize(initial_factory, initial_node_context) + @initial_factory = initial_factory + @initial_node_context = initial_node_context end - def self.node_data(factory, node_context) - new(factory).node_data(node_context) + def node_data + @node_data ||= build_node_data end - def initialize(factory) - @factory = factory - @validatable = Validation::Validatable.new(factory) + def node_context + @node_context ||= referenced_factories.inject(initial_node_context) do |node_context, factory| + Node::Context.resolved_reference(node_context, factory.context) + end end - def errors - return validatable.collection if empty_and_allowed_to_be? - - TypeChecker.validate_type(validatable, type: ::Hash) + def factory_to_build + referenced_factories.last || initial_factory + end - validatable.add_errors(validate(raise_on_invalid: false)) if validatable.errors.empty? + def build_node + return unless node_data - validatable.collection + factory_to_build.build_node(node_data, node_context) end - def node_data(node_context) - return build_node_data(node_context) if empty_and_allowed_to_be? + private - TypeChecker.raise_on_invalid_type(factory.context, type: ::Hash) - validate(raise_on_invalid: true) - build_node_data(node_context) + attr_reader :initial_factory, :initial_node_context + + def referenced_factories + @referenced_factories ||= if initial_factory.respond_to?(:resolved_referenced_factories) + initial_factory.resolved_referenced_factories + else + [] + end end - private_class_method :new + def build_node_data + empty_and_allowed_to_be = initial_factory.nil_input? && initial_factory.can_use_default? + return resolve_node_data_values(initial_factory.data) if empty_and_allowed_to_be - private + validate + + data = merged_node_data - attr_reader :factory, :validatable + # remove any references we have + data.delete("$ref") - def empty_and_allowed_to_be? - factory.nil_input? && factory.can_use_default? + resolve_node_data_values(data) end - def validate(raise_on_invalid:) - Validator.call(factory, raise_on_invalid:) + def merged_node_data + factories = [initial_factory] + referenced_factories + + # Use the last factory in a reference chain as the base, then merge + # data onto it + base_data = factories.last.data + + factories.reverse[1..].inject(base_data) do |memo, factory| + sliced_data = factory.data.slice(*factory.context.input.keys) + memo.merge(sliced_data) + end end - def build_node_data(node_context) - return if factory.nil_input? && factory.data.nil? + def validate + TypeChecker.raise_on_invalid_type(initial_factory.context, type: ::Hash) + Validator.call(initial_factory, raise_on_invalid: true) - factory.data.each_with_object({}) do |(key, value), memo| - memo[key] = resolve_value(key, value, node_context) + # most fields are validated during building, however we delete $ref + # fields so need to validate them separately + ([initial_factory] + referenced_factories).each do |factory| + next unless factory.data.respond_to?(:[]) + next unless factory.data["$ref"].is_a?(NodeFactory::Fields::Reference) + + NodeFactory::Field::Validator.call(factory.data["$ref"], raise_on_invalid: true) end end - def resolve_value(key, value, node_context) - resolved = determine_value_or_default(key, value) + def resolve_node_data_values(factory_data) + return if factory_data.nil? - if resolved.respond_to?(:node) - Node::Placeholder.new(value, key, node_context) - else - resolved + factory_data.each_with_object({}) do |(key, value), memo| + resolved = determine_value_or_default(key, value) + + memo[key] = if resolved.respond_to?(:node) + Node::Placeholder.new(value, key, node_context) + else + resolved + end end end def determine_value_or_default(key, value) - config = factory.field_configs[key] + factory_to_build = referenced_factories.any? ? referenced_factories.last : initial_factory + config = factory_to_build.field_configs[key] # let a field config default take precedence if value is a nil_input? if (value.respond_to?(:nil_input?) && value.nil_input?) || value.nil? - default = config&.default(factory) + default = config&.default(factory_to_build) default.nil? ? value : default else value diff --git a/lib/openapi3_parser/node_factory/object_factory/node_errors.rb b/lib/openapi3_parser/node_factory/object_factory/node_errors.rb new file mode 100644 index 00000000..acb816da --- /dev/null +++ b/lib/openapi3_parser/node_factory/object_factory/node_errors.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Openapi3Parser + module NodeFactory + module ObjectFactory + class NodeErrors + def self.call(factory) + new.call(factory) + end + + def call(factory) + validatable = Validation::Validatable.new(factory) + + return validatable.collection if factory.nil_input? && factory.can_use_default? + + TypeChecker.validate_type(validatable, type: ::Hash) + + validatable.add_errors(Validator.call(factory, raise_on_invalid: false)) if validatable.errors.empty? + + validatable.collection + end + + private_class_method :new + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb b/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb new file mode 100644 index 00000000..09e0c1e1 --- /dev/null +++ b/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Openapi3Parser + module NodeFactory + module ObjectFactory + class ResolvedInputBuilder + def self.call(*args) + new(*args).call + end + + def initialize(initial_factory) + @initial_factory = initial_factory + end + + def call + return if initial_factory.nil_input? + # can't have resolved input if the factory doesn't resolve + return if initial_factory.respond_to?(:resolves?) && !initial_factory.resolves? + + merge_factory_input([initial_factory] + referenced_factories) + end + + private + + attr_reader :initial_factory + + def referenced_factories + @referenced_factories ||= if initial_factory.respond_to?(:resolved_referenced_factories) + initial_factory.resolved_referenced_factories + else + [] + end + end + + def merge_factory_input(factories) + input = factories.reverse.inject({}) do |memo, factory| + next memo unless factory.data.respond_to?(:[]) + + remove_reference = factory.data["$ref"]&.is_a?(NodeFactory::Fields::Reference) + + fields = factory.context.input.keys - (remove_reference ? ["$ref"] : []) + + sliced_data = factory.data.slice(*fields) + memo.merge!(resolve_values(sliced_data)) + end + + input.compact + end + + def resolve_values(data) + data.transform_values do |value| + if value.respond_to?(:in_recursive_loop?) && value.in_recursive_loop? + RecursiveResolvedInput.new(value) + elsif value.respond_to?(:resolved_input) + value.resolved_input + else + value + end + end + end + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index b410e21c..44704d5e 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -36,12 +36,12 @@ def can_use_default? false end - private - - def build_object(data, context) - Node::Openapi.new(data, context) + def build_node(data, node_context) + Node::Openapi.new(data, node_context) end + private + def servers_factory(context) NodeFactory::Array.new(context, default: [{ "url" => "/" }], diff --git a/lib/openapi3_parser/node_factory/operation.rb b/lib/openapi3_parser/node_factory/operation.rb index 714e69f3..c07b2207 100644 --- a/lib/openapi3_parser/node_factory/operation.rb +++ b/lib/openapi3_parser/node_factory/operation.rb @@ -22,14 +22,14 @@ class Operation < NodeFactory::Object field "security", factory: :security_factory field "servers", factory: :servers_factory - private - - def build_object(data, context) - data["servers"] = path_item_server_data(context) if data["servers"].node.empty? + def build_node(data, node_context) + data["servers"] = path_item_server_data(node_context) if data["servers"].node.empty? - Node::Operation.new(data, context) + Node::Operation.new(data, node_context) end + private + def tags_factory(context) NodeFactory::Array.new(context, value_input_type: String) end diff --git a/lib/openapi3_parser/node_factory/parameter.rb b/lib/openapi3_parser/node_factory/parameter.rb index 482d12c1..ac127488 100644 --- a/lib/openapi3_parser/node_factory/parameter.rb +++ b/lib/openapi3_parser/node_factory/parameter.rb @@ -39,12 +39,12 @@ class Parameter < NodeFactory::Object end end - private - - def build_object(data, context) - Node::Parameter.new(data, context) + def build_node(data, node_context) + Node::Parameter.new(data, node_context) end + private + def default_style return "simple" if %w[path header].include?(context.input["in"]) diff --git a/lib/openapi3_parser/node_factory/path_item.rb b/lib/openapi3_parser/node_factory/path_item.rb index 35fe0cce..377784cc 100644 --- a/lib/openapi3_parser/node_factory/path_item.rb +++ b/lib/openapi3_parser/node_factory/path_item.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true require "openapi3_parser/node_factory/object" +require "openapi3_parser/node_factory/referenceable" module Openapi3Parser module NodeFactory class PathItem < NodeFactory::Object + include Referenceable + allow_extensions field "$ref", input_type: String, factory: :ref_factory field "summary", input_type: String @@ -20,29 +23,16 @@ class PathItem < NodeFactory::Object field "servers", factory: :servers_factory field "parameters", factory: :parameters_factory - private - - def build_object(data, node_context) - ref = data.delete("$ref") - context = if node_context.input.keys == %w[$ref] - referenced_factory = ref.node_factory.referenced_factory - Node::Context.resolved_reference( - node_context, - referenced_factory.context - ) - else - node_context - end - - reference_data = ref.nil_input? ? {} : ref.node.node_data - - data = merge_data(reference_data, data).tap do |d| - d["servers"] = root_server_data(context) if d["servers"].node.empty? + def build_node(data, node_context) + data = data.tap do |d| + d["servers"] = root_server_data(node_context) if d["servers"].node.empty? end - Node::PathItem.new(data, context) + Node::PathItem.new(data, node_context) end + private + def ref_factory(context) NodeFactory::Fields::Reference.new(context, self.class) end @@ -58,24 +48,6 @@ def servers_factory(context) ) end - def build_resolved_input - ref = data["$ref"] - data_without_ref = super.tap { |d| d.delete("$ref") } - return data_without_ref unless ref - - merge_data(ref.resolved_input || {}, data_without_ref) - end - - def merge_data(base, priority) - base.merge(priority) do |_, old, new| - if new.nil? || (new.respond_to?(:nil_input?) && new.nil_input?) - old - else - new - end - end - end - def parameters_factory(context) factory = NodeFactory::OptionalReference.new(NodeFactory::Parameter) diff --git a/lib/openapi3_parser/node_factory/paths.rb b/lib/openapi3_parser/node_factory/paths.rb index 715e064d..c937e951 100644 --- a/lib/openapi3_parser/node_factory/paths.rb +++ b/lib/openapi3_parser/node_factory/paths.rb @@ -29,12 +29,12 @@ def initialize(context) validate: :validate) end - private - def build_node(data, node_context) Node::Paths.new(data, node_context) end + private + def validate(validatable) paths = validatable.input.keys.grep_v(NodeFactory::EXTENSION_REGEX) validate_paths(validatable, paths) diff --git a/lib/openapi3_parser/node_factory/recursive_resolved_input.rb b/lib/openapi3_parser/node_factory/recursive_resolved_input.rb new file mode 100644 index 00000000..a2341b9b --- /dev/null +++ b/lib/openapi3_parser/node_factory/recursive_resolved_input.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Openapi3Parser + module NodeFactory + # Used in the place of a hash for resolved input so the value can + # be looked up at runtime avoiding a recursive loop. + class RecursiveResolvedInput + extend Forwardable + include Enumerable + + def_delegators :value, :each, :[], :keys + attr_reader :factory + + def initialize(factory) + @factory = factory + end + + def value + @factory.resolved_input + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/reference.rb b/lib/openapi3_parser/node_factory/reference.rb index f906a82a..91ba7daf 100644 --- a/lib/openapi3_parser/node_factory/reference.rb +++ b/lib/openapi3_parser/node_factory/reference.rb @@ -1,10 +1,13 @@ # frozen_string_literal: true require "openapi3_parser/node_factory/object" +require "openapi3_parser/node_factory/referenceable" module Openapi3Parser module NodeFactory class Reference < NodeFactory::Object + include Referenceable + field "$ref", input_type: String, required: true, factory: :ref_factory attr_reader :factory @@ -14,47 +17,11 @@ def initialize(context, factory) super(context) end - def in_recursive_loop? - data["$ref"].self_referencing? - end - - def referenced_factory - data["$ref"].referenced_factory - end - - def resolves?(control_factory = nil) - control_factory ||= self - - return true unless referenced_factory.is_a?(Reference) - # recursive loop of references that never references an object - return false if referenced_factory == control_factory - - referenced_factory.resolves?(control_factory) - end - - def errors - if in_recursive_loop? - @errors ||= Validation::ErrorCollection.new - else - super - end - end - private - def build_node(node_context) - TypeChecker.raise_on_invalid_type(context, type: ::Hash) - ObjectFactory::Validator.call(self, raise_on_invalid: true) - data["$ref"].node(node_context) - end - def ref_factory(context) NodeFactory::Fields::Reference.new(context, factory) end - - def build_resolved_input - data["$ref"].resolved_input - end end end end diff --git a/lib/openapi3_parser/node_factory/referenceable.rb b/lib/openapi3_parser/node_factory/referenceable.rb new file mode 100644 index 00000000..7076b11b --- /dev/null +++ b/lib/openapi3_parser/node_factory/referenceable.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Openapi3Parser + module NodeFactory + module Referenceable + def in_recursive_loop? + return false unless data.respond_to?(:[]) + + data["$ref"]&.self_referencing? + end + + def referenced_factory + return unless data.respond_to?(:[]) + + data["$ref"]&.referenced_factory + end + + def resolves?(control_locations = nil) + control_locations ||= [context.source_location] + + return true unless referenced_factory.respond_to?(:resolves?) + # recursive loop of references that never references an object + return false if control_locations.include?(referenced_factory.context.source_location) + + referenced_factory.resolves?(control_locations + [context.source_location]) + end + + def errors + if in_recursive_loop? + @errors ||= Validation::ErrorCollection.new + else + super + end + end + + def resolved_referenced_factories + @resolved_referenced_factories ||= if resolves? + collect_referenced_factories(self) + else + [] + end + end + + private + + def collect_referenced_factories(factory, referenced_factories = []) + return referenced_factories unless factory.respond_to?(:referenced_factory) + + if factory.referenced_factory + referenced_factories << factory.referenced_factory + collect_referenced_factories(factory.referenced_factory, referenced_factories) + end + + referenced_factories + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/request_body.rb b/lib/openapi3_parser/node_factory/request_body.rb index f26801bb..864e06ad 100644 --- a/lib/openapi3_parser/node_factory/request_body.rb +++ b/lib/openapi3_parser/node_factory/request_body.rb @@ -10,12 +10,12 @@ class RequestBody < NodeFactory::Object field "content", factory: :content_factory, required: true field "required", input_type: :boolean, default: false - private - - def build_object(data, context) - Node::RequestBody.new(data, context) + def build_node(data, node_context) + Node::RequestBody.new(data, node_context) end + private + def content_factory(context) NodeFactory::Map.new( context, diff --git a/lib/openapi3_parser/node_factory/response.rb b/lib/openapi3_parser/node_factory/response.rb index b0a2ccc3..8727efda 100644 --- a/lib/openapi3_parser/node_factory/response.rb +++ b/lib/openapi3_parser/node_factory/response.rb @@ -11,12 +11,12 @@ class Response < NodeFactory::Object field "content", factory: :content_factory field "links", factory: :links_factory - private - - def build_object(data, context) - Node::Response.new(data, context) + def build_node(data, node_context) + Node::Response.new(data, node_context) end + private + def headers_factory(context) factory = NodeFactory::OptionalReference.new(NodeFactory::Header) NodeFactory::Map.new(context, value_factory: factory) diff --git a/lib/openapi3_parser/node_factory/responses.rb b/lib/openapi3_parser/node_factory/responses.rb index 45e860cb..cc01a4fb 100644 --- a/lib/openapi3_parser/node_factory/responses.rb +++ b/lib/openapi3_parser/node_factory/responses.rb @@ -24,12 +24,12 @@ def initialize(context) validate: :validate_keys) end - private - def build_node(data, node_context) Node::Responses.new(data, node_context) end + private + def validate_keys(validatable) invalid = validatable.input.keys.reject do |key| NodeFactory::EXTENSION_REGEX.match(key) || diff --git a/lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb b/lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb new file mode 100644 index 00000000..968673aa --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "openapi3_parser/node_factory/object" +require "openapi3_parser/node_factory/referenceable" + +module Openapi3Parser + module NodeFactory + module Schema + class OasDialect3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase + include Referenceable + # 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 "properties", factory: :properties_factory + + def build_node(data, node_context) + Node::Schema::OasDialect3_1.new(data, node_context) + end + + private + + def ref_factory(context) + NodeFactory::Fields::Reference.new(context, self.class) + end + + def properties_factory(context) + NodeFactory::Map.new( + context, + value_factory: NodeFactory::Schema.factory(context) + ) + end + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb b/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb deleted file mode 100644 index cd6ed99e..00000000 --- a/lib/openapi3_parser/node_factory/schema/oas_dialect_3_1.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require "openapi3_parser/node_factory/object" - -module Openapi3Parser - module NodeFactory - module Schema - class OasDialect3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase - # Allows any extension as per: - # https://github.com/OAI/OpenAPI-Specification/blob/a1facce1b3621df3630cb692e9fbe18a7612ea6d/versions/3.1.0.md#fixed-fields-20 - allow_extensions(regex: /.*/) - end - end - end -end diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb index 5a07a904..4a6dcac5 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_0.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -50,6 +50,10 @@ class V3_0 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas validate :items_for_array, :read_only_or_write_only + def build_node(data, node_context) + Node::Schema::V3_0.new(data, node_context) + end + private def items_for_array(validatable) @@ -66,10 +70,6 @@ def read_only_or_write_only(validatable) validatable.add_error("readOnly and writeOnly cannot both be true") end - def build_object(data, context) - Node::Schema::V3_0.new(data, context) - end - def required_factory(context) NodeFactory::Array.new( context, diff --git a/lib/openapi3_parser/node_factory/security_requirement.rb b/lib/openapi3_parser/node_factory/security_requirement.rb index 9cb33173..8bffcfdc 100644 --- a/lib/openapi3_parser/node_factory/security_requirement.rb +++ b/lib/openapi3_parser/node_factory/security_requirement.rb @@ -9,8 +9,6 @@ def initialize(context) super(context, value_factory: NodeFactory::Array) end - private - def build_node(data, node_context) Node::SecurityRequirement.new(data, node_context) end diff --git a/lib/openapi3_parser/node_factory/security_scheme.rb b/lib/openapi3_parser/node_factory/security_scheme.rb index 0142ffde..63809a10 100644 --- a/lib/openapi3_parser/node_factory/security_scheme.rb +++ b/lib/openapi3_parser/node_factory/security_scheme.rb @@ -16,12 +16,12 @@ class SecurityScheme < NodeFactory::Object field "flows", factory: :flows_factory field "openIdConnectUrl", input_type: String - private - - def build_object(data, context) + def build_node(data, context) Node::SecurityScheme.new(data, context) end + private + def flows_factory(context) NodeFactory::OauthFlows.new(context) end diff --git a/lib/openapi3_parser/node_factory/server.rb b/lib/openapi3_parser/node_factory/server.rb index e96af45d..6e24ebd0 100644 --- a/lib/openapi3_parser/node_factory/server.rb +++ b/lib/openapi3_parser/node_factory/server.rb @@ -10,12 +10,12 @@ class Server < NodeFactory::Object field "description", input_type: String field "variables", factory: :variables_factory - private - - def build_object(data, context) - Node::Server.new(data, context) + def build_node(data, node_context) + Node::Server.new(data, node_context) end + private + def variables_factory(context) NodeFactory::Map.new( context, diff --git a/lib/openapi3_parser/node_factory/server_variable.rb b/lib/openapi3_parser/node_factory/server_variable.rb index 09398c69..fbb97be8 100644 --- a/lib/openapi3_parser/node_factory/server_variable.rb +++ b/lib/openapi3_parser/node_factory/server_variable.rb @@ -10,6 +10,10 @@ class ServerVariable < NodeFactory::Object field "default", input_type: String, required: true field "description", input_type: String + def build_node(data, node_context) + Node::ServerVariable.new(data, node_context) + end + private def enum_factory(context) @@ -24,10 +28,6 @@ def enum_factory(context) end ) end - - def build_object(data, context) - Node::ServerVariable.new(data, context) - end end end end diff --git a/lib/openapi3_parser/node_factory/tag.rb b/lib/openapi3_parser/node_factory/tag.rb index 9550d845..33a85878 100644 --- a/lib/openapi3_parser/node_factory/tag.rb +++ b/lib/openapi3_parser/node_factory/tag.rb @@ -11,10 +11,8 @@ class Tag < NodeFactory::Object field "description", input_type: String field "externalDocs", factory: NodeFactory::ExternalDocumentation - private - - def build_object(data, context) - Node::Tag.new(data, context) + def build_node(data, node_context) + Node::Tag.new(data, node_context) end end end diff --git a/lib/openapi3_parser/node_factory/xml.rb b/lib/openapi3_parser/node_factory/xml.rb index d261b9c6..fe43e25a 100644 --- a/lib/openapi3_parser/node_factory/xml.rb +++ b/lib/openapi3_parser/node_factory/xml.rb @@ -16,10 +16,8 @@ class Xml < NodeFactory::Object field "attribute", input_type: :boolean, default: false field "wrapped", input_type: :boolean, default: false - private - - def build_object(data, context) - Node::Xml.new(data, context) + def build_node(data, node_context) + Node::Xml.new(data, node_context) end end end diff --git a/lib/openapi3_parser/source/resolved_reference.rb b/lib/openapi3_parser/source/resolved_reference.rb index 0fac2d28..114a2e4e 100644 --- a/lib/openapi3_parser/source/resolved_reference.rb +++ b/lib/openapi3_parser/source/resolved_reference.rb @@ -8,7 +8,6 @@ class ResolvedReference extend Forwardable def_delegators :source_location, :source - def_delegators :factory, :resolved_input, :node attr_reader :source_location, :object_type diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb index bf8127c7..649c47d1 100644 --- a/spec/integration/open_v3.1_examples_spec.rb +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -36,12 +36,19 @@ context "when using the schema I created to demonstrate changes" do let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "changes.yaml") } - xit "is a valid document" do + it "is a valid document" do expect(document).to be_valid end - xit "can access the version" do + it "can access the version" do expect(document.openapi).to eq "3.1.0" end + + it "can access a referenced schema" do + expect(document.components.schemas["DoubleReferencedSchema"]["required"]) + .to match_array(%w[id name]) + expect(document.components.schemas["DoubleReferencedSchema"]["description"]) + .to eq("My double referenced schema") + end end end diff --git a/spec/lib/openapi3_parser/node/context_spec.rb b/spec/lib/openapi3_parser/node/context_spec.rb index eb190c79..b4a9fc1c 100644 --- a/spec/lib/openapi3_parser/node/context_spec.rb +++ b/spec/lib/openapi3_parser/node/context_spec.rb @@ -9,6 +9,20 @@ expect(instance).to be_a(described_class) expect(instance.document_location.to_s).to eq "#/" end + + it "sets an input location based on the factory source location" do + factory_context = create_node_factory_context({}) + instance = described_class.root(factory_context) + + expect(instance.input_locations).to match_array(factory_context.source_location) + end + + it "only sets an input location if it isn't a reference" do + factory_context = create_node_factory_context({ "$ref" => "reference" }) + instance = described_class.root(factory_context) + + expect(instance.input_locations).to be_empty + end end describe ".next_field" do @@ -20,53 +34,135 @@ expect(instance).to be_a(described_class) expect(instance.document_location.to_s).to eq "#/key" end + + it "adds an input location if the data is not a reference" do + parent_context = create_node_context({}) + factory_context = create_node_factory_context({}) + instance = described_class.next_field(parent_context, "key", factory_context) + + expect(instance.input_locations).to include(factory_context.source_location) + end + + it "skips an input location if the data is just a reference" do + parent_context = create_node_context({}) + factory_context = create_node_factory_context({ "$ref" => "reference" }) + instance = described_class.next_field(parent_context, "key", factory_context) + + expect(instance.input_locations).not_to include(factory_context.source_location) + end end describe ".resolved_reference" do - let(:current_context) do - create_node_context({}, pointer_segments: %w[field]) - end + it "returns a context object with the referenced data merged without $ref" do + current_context = create_node_context( + { "$ref" => "#/reference", "first_name" => "John" }, + pointer_segments: %w[field] + ) - let(:reference_factory_context) do - source_location = create_source_location( - {}, - document: current_context.document, - pointer_segments: %w[data] + reference_source_location = create_source_location({}, + document: current_context.document, + pointer_segments: %w[reference]) + + reference_factory_context = Openapi3Parser::NodeFactory::Context.new( + { "first_name" => "Jake", "last_name" => "Smith" }, + source_location: reference_source_location, + reference_locations: [reference_source_location] ) - reference_location = create_source_location( - {}, - document: current_context.document, - pointer_segments: %w[field $ref] + instance = described_class.resolved_reference(current_context, + reference_factory_context) + expect(instance.input).to eq( + { "first_name" => "John", "last_name" => "Smith" } + ) + end + + it "doesn't merge data that is not an object" do + current_context = create_node_context( + { "$ref" => "#/reference", "another" => "field" }, + pointer_segments: %w[field] ) - Openapi3Parser::NodeFactory::Context.new( + reference_source_location = create_source_location({}, + document: current_context.document, + pointer_segments: %w[reference]) + + reference_factory_context = Openapi3Parser::NodeFactory::Context.new( "data", - source_location:, - reference_locations: [reference_location] + source_location: reference_source_location, + reference_locations: [reference_source_location] ) - end - it "returns a context object with the referenced data" do instance = described_class.resolved_reference(current_context, reference_factory_context) - - expect(instance).to be_a(described_class) - expect(instance.input).to eq "data" + expect(instance.input).to eq("data") end it "maintains the document location of the current context" do + current_context = create_node_context( + { "$ref" => "#/reference" }, + pointer_segments: %w[field] + ) + + reference_source_location = create_source_location({}, + document: current_context.document, + pointer_segments: %w[reference]) + + reference_factory_context = Openapi3Parser::NodeFactory::Context.new( + {}, + source_location: reference_source_location, + reference_locations: [reference_source_location] + ) + instance = described_class.resolved_reference(current_context, reference_factory_context) expect(instance.document_location.to_s).to eq "#/field" end - it "sets the source location to the location of the referenced data" do + it "sets the source locations to all the reference locations" do + current_context = create_node_context( + { "$ref" => "#/reference" }, + pointer_segments: %w[field] + ) + + reference_source_location = create_source_location({}, + document: current_context.document, + pointer_segments: %w[reference]) + + reference_factory_context = Openapi3Parser::NodeFactory::Context.new( + {}, + source_location: reference_source_location, + reference_locations: [reference_source_location] + ) + + instance = described_class.resolved_reference(current_context, + reference_factory_context) + + expect(instance.source_locations).to eq( + [current_context.source_locations.first, reference_source_location] + ) + end + + it "sets the input locations to all the references that defined the data" do + current_context = create_node_context( + { "$ref" => "#/reference" }, + pointer_segments: %w[field] + ) + + reference_source_location = create_source_location({}, + document: current_context.document, + pointer_segments: %w[reference]) + + reference_factory_context = Openapi3Parser::NodeFactory::Context.new( + {}, + source_location: reference_source_location, + reference_locations: [reference_source_location] + ) + instance = described_class.resolved_reference(current_context, reference_factory_context) - expect(instance.source_location.to_s).to eq "#/data" + expect(instance.input_locations).to eq([reference_source_location]) end end @@ -83,19 +179,22 @@ it "returns true when input and locations match" do instance = described_class.new({}, - document_location:, - source_location:) + document_location: document_location, + source_locations: [source_location], + input_locations: [source_location]) other = described_class.new({}, - document_location:, - source_location:) + document_location: document_location, + source_locations: [source_location], + input_locations: [source_location]) expect(instance).to eq(other) end it "returns false when one of these differ" do instance = described_class.new({}, - document_location:, - source_location:) + document_location: document_location, + source_locations: [source_location], + input_locations: [source_location]) other_source_location = create_source_location( {}, @@ -104,14 +203,15 @@ ) other = described_class.new({}, - document_location:, - source_location: other_source_location) + document_location: document_location, + source_locations: [other_source_location], + input_locations: [other_source_location]) expect(instance).not_to eq(other) end end - describe "#same_data_and_source?" do + describe "#same_data_inputs?" do let(:source_location) do create_source_location({}, pointer_segments: %w[ref_a]) end @@ -128,26 +228,43 @@ pointer_segments: %w[field_b]) end - it "returns true when input and source location match" do + it "returns true when input and input locations match" do instance = described_class.new({}, - document_location:, - source_location:) + document_location: document_location, + source_locations: [source_location], + input_locations: [source_location]) other = described_class.new({}, document_location: other_document_location, - source_location:) + source_locations: [source_location], + input_locations: [source_location]) - expect(instance.same_data_and_source?(other)).to be true + expect(instance.same_data_inputs?(other)).to be true end - it "returns false when input and source location doesn't match" do + it "returns false when input doesn't match" do instance = described_class.new({}, - document_location:, - source_location:) + document_location: document_location, + source_locations: [source_location], + input_locations: [source_location]) other = described_class.new({ different: "data" }, document_location: other_document_location, - source_location:) + source_locations: [source_location], + input_locations: [source_location]) + + expect(instance.same_data_inputs?(other)).to be false + end + + it "returns false when input locations don't match" do + instance = described_class.new({}, + document_location: document_location, + source_locations: [source_location], + input_locations: [source_location]) + other = described_class.new({}, + document_location: other_document_location, + source_locations: [source_location], + input_locations: []) - expect(instance.same_data_and_source?(other)).to be false + expect(instance.same_data_inputs?(other)).to be false end end diff --git a/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb b/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb index 275a7e9c..574b95c3 100644 --- a/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb @@ -9,29 +9,13 @@ pointer_segments: %w[field $ref] ) end + let(:document_input) { {} } describe "#resolved_input" do - let(:instance) { described_class.new(factory_context, factory_class) } - - context "when reference can be resolved" do - let(:document_input) do - { "reference" => { "name" => "Joe" } } - end - - it "returns the resolved input" do - expect(instance.resolved_input) - .to match(hash_including({ "name" => "Joe" })) - end - end - - context "when reference can't be resolved" do - let(:document_input) do - { "not_reference" => {} } - end - - it "returns nil" do - expect(instance.resolved_input).to be_nil - end + it "raises an error because a reference itself isn't resolved" do + instance = described_class.new(factory_context, factory_class) + expect { instance.resolved_input } + .to raise_error(Openapi3Parser::Error, "References can't have a resolved input") end end @@ -39,26 +23,9 @@ let(:instance) { described_class.new(factory_context, factory_class) } let(:node_context) { node_factory_context_to_node_context(factory_context) } - context "when the reference can be resolved" do - let(:document_input) do - { "reference" => { "name" => "Joe" } } - end - - it "returns an instance of the referenced node" do - expect(instance.node(node_context)) - .to be_a(Openapi3Parser::Node::Contact) - end - end - - context "when the reference can't be resolved" do - let(:document_input) do - { "reference" => { "url" => "invalid url" } } - end - - it "raises an error" do - expect { instance.node(node_context) } - .to raise_error(Openapi3Parser::Error::InvalidData) - end + it "raises an error because references are a replaced node" do + expect { instance.node(node_context) } + .to raise_error(Openapi3Parser::Error, "Reference fields can't be built as a node") end end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb index 0302c295..0494d538 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb @@ -1,104 +1,321 @@ # frozen_string_literal: true RSpec.describe Openapi3Parser::NodeFactory::ObjectFactory::NodeBuilder do - describe ".errors" do - it "returns an error collection" do - factory = Openapi3Parser::NodeFactory::Contact.new( - create_node_factory_context(nil) - ) + describe "#node_data" do + context "when factory input is nil" do + let(:factory_context) { create_node_factory_context(nil) } - expect(described_class.errors(factory)) - .to be_a(Openapi3Parser::Validation::ErrorCollection) - end + it "returns nil for a node with no data fields" do + node_builder = described_class.new( + Openapi3Parser::NodeFactory::Object.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) - it "returns an empty collection when there aren't errors" do - factory = Openapi3Parser::NodeFactory::Contact.new( - create_node_factory_context({ "email" => "valid-email@example.com" }) - ) + expect(node_builder.node_data).to be_nil + end - expect(described_class.errors(factory)).to be_empty - end + it "returns nil for a factory with fields and a nil default" do + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + field "name" - it "returns errors when the type is correct but the data is not" do - factory = Openapi3Parser::NodeFactory::Contact.new( - create_node_factory_context({ "email" => "invalid email" }) - ) + def default + nil + end + end + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect(node_builder.node_data).to be_nil + end - expect(described_class.errors(factory)).not_to be_empty + it "an object with field defaults for a factory with fields" do + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + field "name" + + def default + {} + end + end + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect(node_builder.node_data).to eq({ "name" => nil }) + end end - it "returns errors when given an unexpected type" do - factory = Openapi3Parser::NodeFactory::Contact.new( - create_node_factory_context(123) - ) + context "when there is factory input" do + it "raises an error if the data isn't the expected type" do + factory_context = create_node_factory_context("not a hash") + + node_builder = described_class.new( + Openapi3Parser::NodeFactory::Object.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect { node_builder.node_data }.to raise_error(Openapi3Parser::Error::InvalidType) + end + + it "raises an error if the the data isn't valid" do + factory_context = create_node_factory_context({}) + + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + field "name", required: true + end + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect { node_builder.node_data }.to raise_error(Openapi3Parser::Error::MissingFields) + end + + it "returns an object of the node's data" do + factory_context = create_node_factory_context({ "name" => "Steve" }) + + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + field "name" + end + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect(node_builder.node_data).to eq({ "name" => "Steve" }) + end + + it "populates any missing fields with their default" do + factory_context = create_node_factory_context({}) + + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + field "name", default: "Joe Bloggs" + end + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect(node_builder.node_data).to eq({ "name" => "Joe Bloggs" }) + end + + it "assigns Node::Placeholder objects for any fields that are nodes" do + factory_context = create_node_factory_context( + { "contact" => { "name" => "Joe Bloggs" } } + ) + + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + field "contact", factory: Openapi3Parser::NodeFactory::Contact + end + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) - expect(described_class.errors(factory)).not_to be_empty + expect(node_builder.node_data) + .to match({ "contact" => an_instance_of(Openapi3Parser::Node::Placeholder) }) + end end - context "when input is nil" do + context "when the factory includes a reference to other nodes" do + let(:document_input) do + { + "components" => { + "schemas" => { + "Referenced" => { + "first_name" => "Joe", + "last_name" => "Bloggs" + } + } + } + } + end + let(:factory) do - Openapi3Parser::NodeFactory::Contact.new( - create_node_factory_context(nil) + Class.new(Openapi3Parser::NodeFactory::Object) do + include Openapi3Parser::NodeFactory::Referenceable + + field "$ref", factory: :ref_factory + field "first_name" + field "last_name" + + def ref_factory(context) + Openapi3Parser::NodeFactory::Fields::Reference.new(context, self.class) + end + end + end + + it "returns the data merging together reference values" do + factory_context = create_node_factory_context( + { "$ref" => "#/components/schemas/Referenced", + "last_name" => "Smith" }, + document_input: document_input + ) + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) ) + + expect(node_builder.node_data) + .to match({ "first_name" => "Joe", "last_name" => "Smith" }) end - it "returns an empty collection when the factory allows a default" do - allow(factory).to receive(:can_use_default?).and_return(true) - expect(described_class.errors(factory)).to be_empty + it "allows a nil input to replace a referenced field" do + factory_context = create_node_factory_context( + { "$ref" => "#/components/schemas/Referenced", + "last_name" => nil }, + document_input: document_input + ) + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect(node_builder.node_data) + .to match({ "first_name" => "Joe", "last_name" => nil }) end - it "returns an error when the factory doesn't allow a default" do - allow(factory).to receive(:can_use_default?).and_return(false) - expect(described_class.errors(factory)).not_to be_empty + it "returns the data without any $ref fields" do + factory_context = create_node_factory_context( + { "$ref" => "#/components/schemas/Referenced" }, + document_input: document_input + ) + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect(node_builder.node_data.keys).not_to include("$ref") + end + + it "raises an error if a reference is broken" do + factory_context = create_node_factory_context( + { "$ref" => "#/components/schemas/Broken" }, + document_input: document_input + ) + + node_builder = described_class.new( + factory.new(factory_context), + node_factory_context_to_node_context(factory_context) + ) + + expect { node_builder.node_data } + .to raise_error(Openapi3Parser::Error::InvalidData) end end end - describe ".node_data" do - it "returns the data for a node" do - factory_context = create_node_factory_context({ "name" => "Tom" }) - factory = Openapi3Parser::NodeFactory::Contact.new(factory_context) - node_context = node_factory_context_to_node_context(factory_context) + describe "#node_context" do + context "when the factory doesn't have references" do + it "returns the given node context" do + factory_context = create_node_factory_context(nil) + node_context = node_factory_context_to_node_context(factory_context) + node_builder = described_class.new( + Openapi3Parser::NodeFactory::Object.new(factory_context), + node_context + ) - expect(described_class.node_data(factory, node_context)) - .to match(hash_including({ "name" => "Tom" })) + expect(node_builder.node_context).to be(node_context) + end end - it "raises an error when given invalid data" do - factory_context = create_node_factory_context({ "email" => "invalid email" }) - factory = Openapi3Parser::NodeFactory::Contact.new(factory_context) - node_context = node_factory_context_to_node_context(factory_context) + context "when the factory has references" do + it "returns a node context appropriate for the references" do + factory = Class.new(Openapi3Parser::NodeFactory::Object) do + include Openapi3Parser::NodeFactory::Referenceable - expect { described_class.node_data(factory, node_context) } - .to raise_error(Openapi3Parser::Error::InvalidData) + field "$ref", factory: :ref_factory + field "name" + + def ref_factory(context) + Openapi3Parser::NodeFactory::Fields::Reference.new(context, self.class) + end + end + + factory_context = create_node_factory_context( + { "$ref" => "#/components/schemas/Reference" }, + document_input: { + "components" => { + "schemas" => { + "Reference" => { + "name" => "Joe" + } + } + } + } + ) + + node_context = node_factory_context_to_node_context(factory_context) + node_builder = described_class.new(factory.new(factory_context), node_context) + + expect(node_builder.node_context.source_locations.map(&:to_s)) + .to eq(["#/", "#/components/schemas/Reference"]) + end end + end - it "raises an error when given an unexpected type for the data" do - factory_context = create_node_factory_context(123) - factory = Openapi3Parser::NodeFactory::Contact.new(factory_context) - node_context = node_factory_context_to_node_context(factory_context) + describe "#factory_to_build" do + it "returns the given factory for a factory without references" do + factory_context = create_node_factory_context(nil) + factory = Openapi3Parser::NodeFactory::Object.new(factory_context) + node_builder = described_class.new(factory, node_factory_context_to_node_context(factory_context)) - expect { described_class.node_data(factory, node_context) } - .to raise_error(Openapi3Parser::Error::InvalidType) + expect(node_builder.factory_to_build).to be(factory) end - context "when input is nil" do - let(:factory_context) { create_node_factory_context(nil) } - let(:factory) { Openapi3Parser::NodeFactory::Contact.new(factory_context) } - let(:node_context) do - node_factory_context_to_node_context(factory_context) + it "returns the last referenced factory for a factory with references" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + field "name" end - it "returns nil when the factory allows a default" do - allow(factory).to receive(:can_use_default?).and_return(true) - expect(described_class.node_data(factory, node_context)).to be_nil - end + factory_context = create_node_factory_context( + { "$ref" => "#/Referenced" }, + document_input: { + "Referenced" => { + "name" => "Joe" + } + } + ) + + factory = Openapi3Parser::NodeFactory::OptionalReference.new(factory_class).call(factory_context) + node_builder = described_class.new(factory, node_factory_context_to_node_context(factory_context)) + + expect(node_builder.factory_to_build).to be_an_instance_of(factory_class) + end + end - it "raises an error when the factory doesn't allow a default" do - allow(factory).to receive(:can_use_default?).and_return(false) - expect { described_class.node_data(factory, node_context) } - .to raise_error(Openapi3Parser::Error::InvalidType) + describe "#build_node" do + it "returns nil if no node_data was determined" do + factory_context = create_node_factory_context(nil) + factory = Openapi3Parser::NodeFactory::Object.new(factory_context) + node_builder = described_class.new(factory, node_factory_context_to_node_context(factory_context)) + + expect(node_builder.build_node).to be_nil + end + + it "returns a created node for the last referenced factory if there is build data" do + factory_context = create_node_factory_context({}) + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + def build_node(data, node_context) + Openapi3Parser::Node::Object.new(data, node_context) + end end + + node_builder = described_class.new(factory_class.new(factory_context), + node_factory_context_to_node_context(factory_context)) + + expect(node_builder.build_node).to be_an_instance_of(Openapi3Parser::Node::Object) end end end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/node_errors_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/node_errors_spec.rb new file mode 100644 index 00000000..bd63a4d1 --- /dev/null +++ b/spec/lib/openapi3_parser/node_factory/object_factory/node_errors_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::NodeFactory::ObjectFactory::NodeErrors do + describe ".call" do + it "returns a validation collection" do + factory_class = Openapi3Parser::NodeFactory::Object + + factory_context = create_node_factory_context(1) + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors).to be_an_instance_of(Openapi3Parser::Validation::ErrorCollection) + end + + it "has validation errors for input other than an object" do + factory_class = Openapi3Parser::NodeFactory::Object + factory_context = create_node_factory_context("not an object") + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors).not_to be_empty + end + + it "has no validation errors for nil input with an allowed default" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + def can_use_default? + true + end + end + + factory_context = create_node_factory_context(nil) + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors).to be_empty + end + + it "has no validation errors for an object without issues" do + factory_class = Openapi3Parser::NodeFactory::Object + factory_context = create_node_factory_context({}) + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors).to be_empty + end + + it "has validation errors for nil input and can't use default" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + def can_use_default? + false + end + end + + factory_context = create_node_factory_context(nil) + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors).not_to be_empty + end + + it "has validation errors for factory validation issues" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + validate do |validatable| + validatable.add_error("Error") + end + end + + factory_context = create_node_factory_context({}) + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors).not_to be_empty + end + + it "doesn't validate the object if there is a type error" do + factory_class = Class.new(Openapi3Parser::NodeFactory::Object) do + validate do |validatable| + validatable.add_error("Error") + end + end + + factory_context = create_node_factory_context("not an object") + errors = described_class.call(factory_class.new(factory_context)) + + expect(errors.count).to be(1) + expect(errors.first.message).to match(/invalid type/i) + end + end +end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb new file mode 100644 index 00000000..a00b3686 --- /dev/null +++ b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::NodeFactory::ObjectFactory::ResolvedInputBuilder do + describe ".call" do + context "when a factory doesn't have references" do + it "returns the objects data" do + factory_context = create_node_factory_context({ "field" => "value" }) + factory = Openapi3Parser::NodeFactory::Object.new(factory_context) + + expect(described_class.call(factory)).to eq({ "field" => "value" }) + end + + it "returns nil for a factory with nil data" do + factory_context = create_node_factory_context(nil) + factory = Openapi3Parser::NodeFactory::Object.new(factory_context) + + expect(described_class.call(factory)).to be_nil + end + end + + context "when a factory has references" do + let(:factory_class) do + Class.new(Openapi3Parser::NodeFactory::Object) do + include Openapi3Parser::NodeFactory::Referenceable + + field "$ref", factory: :ref_factory + field "first_name" + field "last_name" + + def ref_factory(context) + Openapi3Parser::NodeFactory::Fields::Reference.new(context, self.class) + end + end + end + + it "merges data from factories together" do + factory_context = create_node_factory_context( + { "$ref" => "#/reference_a" }, + document_input: { + "reference_a" => { "$ref" => "#/reference_b", "last_name" => "Smith" }, + "reference_b" => { "first_name" => "John", "last_name" => "Doe" } + } + ) + factory = factory_class.new(factory_context) + + expect(described_class.call(factory)) + .to include({ "first_name" => "John", "last_name" => "Smith" }) + end + + it "removes $ref fields" do + factory_context = create_node_factory_context( + { "$ref" => "#/reference_a" }, + document_input: { + "reference_a" => { "first_name" => "John", "last_name" => "Smith" } + } + ) + factory = factory_class.new(factory_context) + + expect(described_class.call(factory).keys).not_to include("$ref") + end + + it "allows fields to be overriden with nil" do + factory_context = create_node_factory_context( + { "$ref" => "#/reference_a", "last_name" => nil }, + document_input: { + "reference_a" => { "first_name" => "John", "last_name" => "Smith" } + } + ) + factory = factory_class.new(factory_context) + + expect(described_class.call(factory)) + .to match({ "first_name" => "John" }) + end + + it "returns nil if a factory reference doesn't resolve" do + factory_context = create_node_factory_context( + { "$ref" => "#/reference_a" }, + document_input: { + "reference_a" => { "$ref" => "#/reference_b" }, + "reference_b" => { "$ref" => "#/reference_a" } + } + ) + + factory = factory_class.new(factory_context) + + expect(described_class.call(factory)).to be_nil + end + + it "returns a RecursiveResolvedInput object for node data that is in a recursive loop" do + factory_context = create_node_factory_context( + { "$ref" => "#/components/schemas/Reference" }, + document_input: { + "components" => { + "schemas" => { + "Reference" => { + "type" => "object", + "properties" => { + "recursive" => { "$ref" => "#/components/schemas/Reference" } + } + } + } + } + } + ) + + factory = Openapi3Parser::NodeFactory::Schema::OasDialect3_1.new(factory_context) + + expect(described_class.call(factory)).to match( + { + "type" => "object", + "properties" => { + "recursive" => { + "type" => "object", + "properties" => { + "recursive" => an_instance_of(Openapi3Parser::NodeFactory::RecursiveResolvedInput) + } + } + } + } + ) + end + end + end +end diff --git a/spec/lib/openapi3_parser/node_factory/path_item_spec.rb b/spec/lib/openapi3_parser/node_factory/path_item_spec.rb index 9c837a24..de8ee518 100644 --- a/spec/lib/openapi3_parser/node_factory/path_item_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/path_item_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true RSpec.describe Openapi3Parser::NodeFactory::PathItem do + # TODO: perhaps a behaves like referenceable node object factory? + it_behaves_like "node object factory", Openapi3Parser::Node::PathItem do let(:input) do { @@ -119,12 +121,6 @@ expect(node.summary).to eq "My summary" expect(node.parameters[0].name).to eq "id" end - - it "sets the source location to be the refrence path" do - node = create_node(input, document_input) - expect(node.node_context.source_location.to_s) - .to eq "#/path_items/example" - end end context "when the input includes fields besides a reference" do @@ -136,11 +132,6 @@ node = create_node(input, document_input) expect(node.summary).to eq "A different summary" end - - it "sets the source location to be the original node" do - node = create_node(input, document_input) - expect(node.node_context.source_location.to_s).to eq "#/" - end end end diff --git a/spec/lib/openapi3_parser/node_factory/reference_spec.rb b/spec/lib/openapi3_parser/node_factory/reference_spec.rb index eaf98fe9..dc9ca215 100644 --- a/spec/lib/openapi3_parser/node_factory/reference_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/reference_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true RSpec.describe Openapi3Parser::NodeFactory::Reference do + # TODO: perhaps a behaves like referenceable node object factory? + def create_instance(input) factory_context = create_node_factory_context( input, @@ -88,13 +90,6 @@ def create_node(input) ) end - def control_factory(instance) - # As references need to be registered and this happens in the process - # of creating a reference node we need to check reference loop using - # a factory from the reference registry - instance.context.source.reference_registry.factories.first - end - it "returns true when following a chain of references leads to an object" do factory_context = create_node_factory_context( { "$ref" => "#/contact2" }, @@ -107,7 +102,7 @@ def control_factory(instance) ) instance = described_class.new(factory_context, factory) - expect(instance.resolves?(control_factory(instance))).to be true + expect(instance.resolves?([instance.context.source_location])).to be true end it "returns false when following a chain of references leads to a recursive loop" do @@ -122,7 +117,7 @@ def control_factory(instance) ) instance = described_class.new(factory_context, factory) - expect(instance.resolves?(control_factory(instance))).to be false + expect(instance.resolves?([instance.context.source_location])).to be false end end end diff --git a/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb new file mode 100644 index 00000000..ceae6705 --- /dev/null +++ b/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::NodeFactory::Schema::OasDialect3_1 do + # TODO: perhaps a behaves like referenceable node object factory? + + # Basic c+p of V3_0 Schema test for now + it_behaves_like "node object factory", Openapi3Parser::Node::Schema::OasDialect3_1 do + let(:input) do + { + "allOf" => [ + { "$ref" => "#/components/schemas/Pet" }, + { + "type" => "object", + "properties" => { + "bark" => { "type" => "string" } + } + } + ] + } + end + + let(:document_input) do + { + "components" => { + "schemas" => { + "Pet" => { + "type" => "object", + "required" => %w[pet_type], + "properties" => { + "pet_type" => { "type" => "string" } + }, + "discriminator" => { + "propertyName" => "pet_type", + "mapping" => { "cachorro" => "Dog" } + } + } + } + } + } + end + + let(:node_factory_context) do + create_node_factory_context(input, document_input: document_input) + end + + let(:node_context) do + node_factory_context_to_node_context(node_factory_context) + end + end +end diff --git a/spec/support/helpers/context.rb b/spec/support/helpers/context.rb index 34a6eaea..74fe2452 100644 --- a/spec/support/helpers/context.rb +++ b/spec/support/helpers/context.rb @@ -29,23 +29,26 @@ def create_node_factory_context(input, end def node_factory_context_to_node_context(node_factory_context) - Openapi3Parser::Node::Context.new( - node_factory_context.input, - document_location: node_factory_context.source_location, - source_location: node_factory_context.source_location - ) + input = node_factory_context.input + source_location = node_factory_context.source_location + input_locations = Openapi3Parser::Node::Context.input_location?(input) ? [source_location] : [] + + Openapi3Parser::Node::Context.new(input, + document_location: source_location, + source_locations: [source_location], + input_locations: input_locations) end def create_node_context(input, document_input: {}, pointer_segments: []) source_input = Openapi3Parser::SourceInput::Raw.new(document_input) document = Openapi3Parser::Document.new(source_input) - location = Openapi3Parser::Source::Location.new( - document.root_source, - pointer_segments - ) + location = Openapi3Parser::Source::Location.new(document.root_source, pointer_segments) + + input_locations = Openapi3Parser::Node::Context.input_location?(input) ? [location] : [] Openapi3Parser::Node::Context.new(input, document_location: location, - source_location: location) + source_locations: [location], + input_locations: input_locations) end end end diff --git a/spec/support/node_equality.rb b/spec/support/node_equality.rb index 0a3742d4..60e3763f 100644 --- a/spec/support/node_equality.rb +++ b/spec/support/node_equality.rb @@ -18,7 +18,8 @@ context.document_location.source, %w[different] ), - source_location: context.source_location + source_locations: context.source_locations, + input_locations: context.source_locations ) other = described_class.new(input, other_context) expect(instance).to eq(other) @@ -32,16 +33,16 @@ it "isn't equal when source is different" do instance = described_class.new(input, context) + source_locations = [Openapi3Parser::Source::Location.new(context.document_location.source, %w[option_a])] + other_context = Openapi3Parser::Node::Context.new( {}, document_location: Openapi3Parser::Source::Location.new( context.document_location.source, - %w[different] + %w[option_b] ), - source_location: Openapi3Parser::Source::Location.new( - context.document_location.source, - %w[different] - ) + source_locations: source_locations, + input_locations: source_locations ) other = described_class.new(input, other_context) From 79df56c81054847c2b3dee375fd92dc24069e25a Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 10 Apr 2022 20:12:01 +0100 Subject: [PATCH 21/53] Mark Node::Context as a long class This is unfortunately rather long, but there doesn't seem any clear single responsibility opportunities that can be embraced so I'm taking the coward/assertive route of telling the linter: no. --- lib/openapi3_parser/node/context.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/openapi3_parser/node/context.rb b/lib/openapi3_parser/node/context.rb index c1b87ea1..3affb423 100644 --- a/lib/openapi3_parser/node/context.rb +++ b/lib/openapi3_parser/node/context.rb @@ -14,6 +14,7 @@ module Node # node # @attr_reader [Source::Location] source_location The location in a # source file of this + # rubocop:disable Metrics/ClassLength class Context # Create a context for the root of a document # @@ -210,5 +211,6 @@ def openapi_version document.openapi_version end end + # rubocop:enable Metrics/ClassLength end end From 4f213a15aa8ba4cfaebd50b89c1ea8e00513c69c Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 1 May 2022 11:59:09 +0100 Subject: [PATCH 22/53] Allow 3.1 references to have summary and description In OpenAPI 3.1 references can have summary and description metadata for nodes that support those fields. They operate in a cascading pattern where each reference can overwrite the previous ones. --- TODO.md | 2 +- lib/openapi3_parser/node_factory/reference.rb | 6 ++ .../node_factory/reference_spec.rb | 78 +++++++++++++++++++ spec/support/examples/v3.1/changes.yaml | 12 +++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 36b6723a..4639445c 100644 --- a/TODO.md +++ b/TODO.md @@ -50,7 +50,7 @@ For OpenAPI 3.1 - [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 -- [ ] Allow summary and description in Reference objects +- [x] Allow summary and description in Reference objects - [ ] Add identifier to License node, make mutually exclusive with URL - [ ] ServerVariable enum must not be empty - [ ] Add pathItems to components diff --git a/lib/openapi3_parser/node_factory/reference.rb b/lib/openapi3_parser/node_factory/reference.rb index 91ba7daf..20c8bd41 100644 --- a/lib/openapi3_parser/node_factory/reference.rb +++ b/lib/openapi3_parser/node_factory/reference.rb @@ -9,6 +9,12 @@ class Reference < NodeFactory::Object include Referenceable field "$ref", input_type: String, required: true, factory: :ref_factory + field "summary", + input_type: String, + allowed: ->(context) { context.openapi_version >= "3.1" } + field "description", + input_type: String, + allowed: ->(context) { context.openapi_version >= "3.1" } attr_reader :factory diff --git a/spec/lib/openapi3_parser/node_factory/reference_spec.rb b/spec/lib/openapi3_parser/node_factory/reference_spec.rb index dc9ca215..92dae984 100644 --- a/spec/lib/openapi3_parser/node_factory/reference_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/reference_spec.rb @@ -120,4 +120,82 @@ def create_node(input) expect(instance.resolves?([instance.context.source_location])).to be false end end + + describe "summary field" do + let(:factory) do + Openapi3Parser::NodeFactory::OptionalReference.new( + Openapi3Parser::NodeFactory::Contact + ) + end + + it "accepts a summary field for OpenAPI >= v3.1" do + factory_context = create_node_factory_context( + { + "$ref" => "#/item", + "summary" => "summary contents" + }, + document_input: { + "openapi" => "3.1.0", + "item" => {} + } + ) + expect(described_class.new(factory_context, factory)).to be_valid + end + + it "rejects a summary field for OpenAPI < v3.1" do + factory_context = create_node_factory_context( + { + "$ref" => "#/item", + "summary" => "summary contents" + }, + document_input: { + "openapi" => "3.0.0", + "item" => {} + } + ) + instance = described_class.new(factory_context, factory) + + expect(instance).not_to be_valid + expect(instance).to have_validation_error("#/").with_message("Unexpected fields: summary") + end + end + + describe "description field" do + let(:factory) do + Openapi3Parser::NodeFactory::OptionalReference.new( + Openapi3Parser::NodeFactory::Contact + ) + end + + it "accepts a description field for OpenAPI >= v3.1" do + factory_context = create_node_factory_context( + { + "$ref" => "#/item", + "description" => "description contents" + }, + document_input: { + "openapi" => "3.1.0", + "item" => {} + } + ) + expect(described_class.new(factory_context, factory)).to be_valid + end + + it "rejects a description field for OpenAPI < v3.1" do + factory_context = create_node_factory_context( + { + "$ref" => "#/item", + "description" => "description contents" + }, + document_input: { + "openapi" => "3.0.0", + "item" => {} + } + ) + instance = described_class.new(factory_context, factory) + + expect(instance).not_to be_valid + expect(instance).to have_validation_error("#/").with_message("Unexpected fields: description") + end + end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index c4cfcf05..ff92f785 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -8,6 +8,18 @@ info: # identifier: Apache-2.0 # jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base components: + examples: + FullExample: + summary: Original summary + description: Original description + value: anything + ReferencedExample: + summary: Referenced summary + description: Referenced Description + $ref: "#/components/examples/FullExample" + DoubleReferencedExample: + summary: Double referenced summary + $ref: "#/components/examples/ReferencedExample" schemas: BasicSchema: description: "My basic schema" From 13854ebd119d766ae86256fc8d341787b643d6fd Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Sun, 1 May 2022 17:00:35 +0100 Subject: [PATCH 23/53] Add identifier, as a mutually exclusive field, to License This adds the identifier field to License - as outlined in the OpenAPI 3.1 Specification [1]. A couple of things I considered doing for this, but ultimately didn't do, were: - Determine whether the SPDX license expression format could be validated with a regex. It looked like it probably could but not sure if we want to be that strict. - Add a hook in that would allow us to decide which OpenAPI versions would use the mutually_exclusive DSL method. I decided this wasn't necessary for now since the only benefit seems to be a slightly nicer error message. --- TODO.md | 2 +- lib/openapi3_parser/node/license.rb | 5 +++ lib/openapi3_parser/node_factory/license.rb | 4 +++ .../node_factory/license_spec.rb | 35 +++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 4639445c..2ce4e63d 100644 --- a/TODO.md +++ b/TODO.md @@ -51,7 +51,7 @@ For OpenAPI 3.1 - [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes - [ ] jsonSchemaDialect should default to OAS one - [x] Allow summary and description in Reference objects -- [ ] Add identifier to License node, make mutually exclusive with URL +- [x] Add identifier to License node, make mutually exclusive with URL - [ ] ServerVariable enum must not be empty - [ ] Add pathItems to components - [ ] Callbacks can now reference a PathItem - previously required them diff --git a/lib/openapi3_parser/node/license.rb b/lib/openapi3_parser/node/license.rb index efe64e2c..2151511c 100644 --- a/lib/openapi3_parser/node/license.rb +++ b/lib/openapi3_parser/node/license.rb @@ -11,6 +11,11 @@ def name self["name"] end + # @return [String, nil] + def identifier + self["identifier"] + end + # @return [String, nil] def url self["url"] diff --git a/lib/openapi3_parser/node_factory/license.rb b/lib/openapi3_parser/node_factory/license.rb index bcfc63cc..56eb892c 100644 --- a/lib/openapi3_parser/node_factory/license.rb +++ b/lib/openapi3_parser/node_factory/license.rb @@ -9,9 +9,13 @@ module NodeFactory class License < NodeFactory::Object allow_extensions field "name", input_type: String, required: true + field "identifier", + input_type: String, + allowed: ->(context) { context.openapi_version >= "3.1" } field "url", input_type: String, validate: Validation::InputValidator.new(Validators::Url) + mutually_exclusive "identifier", "url" def build_node(data, node_context) Node::License.new(data, node_context) diff --git a/spec/lib/openapi3_parser/node_factory/license_spec.rb b/spec/lib/openapi3_parser/node_factory/license_spec.rb index 77636397..aa5a302c 100644 --- a/spec/lib/openapi3_parser/node_factory/license_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/license_spec.rb @@ -24,4 +24,39 @@ expect(instance).to have_validation_error("#/url") end end + + describe "identifier field" do + it "accepts an identifier field for OpenAPI >= v3.1" do + factory_context = create_node_factory_context( + minimal_license_definition.merge({ "identifier" => "Apache-2.0" }), + document_input: { "openapi" => "3.1.0" } + ) + expect(described_class.new(factory_context)).to be_valid + end + + it "rejects an identifier field for OpenAPI < v3.1" do + factory_context = create_node_factory_context( + minimal_license_definition.merge({ "identifier" => "Apache-2.0" }), + document_input: { "openapi" => "3.0.0" } + ) + instance = described_class.new(factory_context) + + expect(instance).not_to be_valid + expect(instance).to have_validation_error("#/").with_message("Unexpected fields: identifier") + end + + it "rejects both a license and a url field (mutually exclusive)" do + factory_context = create_node_factory_context( + minimal_license_definition.merge({ + "identifier" => "Apache-2.0", + "url" => "https://example.com/url" + }), + document_input: { "openapi" => "3.1.0" } + ) + instance = described_class.new(factory_context) + + expect(instance).not_to be_valid + expect(instance).to have_validation_error("#/").with_message("identifier and url are mutually exclusive fields") + end + end end From 995b5aef8d774e0f4a0b087449aae82ef760671b Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 8 Jul 2022 19:56:58 +0100 Subject: [PATCH 24/53] No changes needed for ServerVariable We already validate this. In 3.0.x to 3.1.x the spec changed from: > An enumeration of string values to be used if the substitution options are from a limited set. The array SHOULD NOT be empty. to: > An enumeration of string values to be used if the substitution options are from a limited set. The array MUST NOT be empty. --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 2ce4e63d..9b40ab9b 100644 --- a/TODO.md +++ b/TODO.md @@ -52,7 +52,7 @@ For OpenAPI 3.1 - [ ] 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 -- [ ] ServerVariable enum must not be empty +- [x] ServerVariable enum must not be empty - [ ] Add pathItems to components - [ ] Callbacks can now reference a PathItem - previously required them - [ ] Check out whether pathItem references match the rules for relative resolution From 8b4c4a0418d076bce10c1de9331fe6f96a16fea0 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 8 Jul 2022 20:17:09 +0100 Subject: [PATCH 25/53] Add pathItems to components for OpenAPI 3.1 --- .../node_factory/components.rb | 7 +++++ .../node_factory/components_spec.rb | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lib/openapi3_parser/node_factory/components.rb b/lib/openapi3_parser/node_factory/components.rb index 4c723345..9faa647a 100644 --- a/lib/openapi3_parser/node_factory/components.rb +++ b/lib/openapi3_parser/node_factory/components.rb @@ -15,6 +15,9 @@ class Components < NodeFactory::Object field "securitySchemes", factory: :security_schemes_factory field "links", factory: :links_factory field "callbacks", factory: :callbacks_factory + field "pathItems", + factory: :path_items_factory, + allowed: ->(context) { context.openapi_version >= "3.1" } def build_node(data, node_context) Node::Components.new(data, node_context) @@ -62,6 +65,10 @@ def callbacks_factory(context) referenceable_map_factory(context, NodeFactory::Callback) end + def path_items_factory(context) + referenceable_map_factory(context, NodeFactory::PathItem) + end + def referenceable_map_factory(context, factory) NodeFactory::Map.new( context, diff --git a/spec/lib/openapi3_parser/node_factory/components_spec.rb b/spec/lib/openapi3_parser/node_factory/components_spec.rb index 17907a77..b35bc8d9 100644 --- a/spec/lib/openapi3_parser/node_factory/components_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/components_spec.rb @@ -131,4 +131,30 @@ expect(instance).to have_validation_error("#/responses") end end + + describe "pathItems field" do + it "accepts this field for OpenAPI >= 3.1" do + factory_context = create_node_factory_context( + { + "pathItems" => { "key" => { "summary" => "Item summary" } } + }, + document_input: { "openapi" => "3.1.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).to be_valid + end + + it "rejects this field for OpenAPI < 3.1" do + factory_context = create_node_factory_context( + { + "pathItems" => { "key" => { "summary" => "Item summary" } } + }, + document_input: { "openapi" => "3.0.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).not_to be_valid + end + end end From 4711f4c075032141baddb58573ca874f60f385c4 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 8 Jul 2022 20:19:38 +0100 Subject: [PATCH 26/53] Mark mutualTLS as resolved This code doesn't validate the type of a Security Scheme so we don't need to change the code for the new type of mutualTLS. --- TODO.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 9b40ab9b..a744c5a9 100644 --- a/TODO.md +++ b/TODO.md @@ -53,11 +53,11 @@ For OpenAPI 3.1 - [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 -- [ ] Add pathItems to components +- [x] Add pathItems to components - [ ] Callbacks can now reference a PathItem - previously required them - [ ] Check out whether pathItem references match the rules for relative resolution - [ ] Parameter object can have space delimited or pipeDelimited styles - [ ] Discriminator object can be extended -- [ ] mutualTLS as a security scheme +- [x] mutualTLS as a security scheme - [ ] I think strictness of Security Requirement rules has changed From 145cef3c51ffa92c8d488a04a6ec3e65bb2ecf50 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 8 Jul 2022 21:12:37 +0100 Subject: [PATCH 27/53] Version specific allow_extensions for Discriminator A change in OpenAPI 3.1 is that the Discriminator object can accept extensions, when previously this was not allowed. In order to code this I've had to adjust the DSL so that the allow_extensions method can accept a block. We didn't have unit tests for the DSL so I'm only testing this at the node level. This should be ok as it has full coverage. --- TODO.md | 2 +- .../node_factory/discriminator.rb | 2 ++ lib/openapi3_parser/node_factory/object.rb | 1 + .../node_factory/object_factory/dsl.rb | 15 +++++++++- .../node_factory/object_factory/validator.rb | 4 ++- .../node_factory/discriminator_spec.rb | 28 +++++++++++++++++++ 6 files changed, 49 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index a744c5a9..6ba43b48 100644 --- a/TODO.md +++ b/TODO.md @@ -57,7 +57,7 @@ For OpenAPI 3.1 - [ ] Callbacks can now reference a PathItem - previously required them - [ ] Check out whether pathItem references match the rules for relative resolution - [ ] Parameter object can have space delimited or pipeDelimited styles -- [ ] Discriminator object can be extended +- [x] Discriminator object can be extended - [x] mutualTLS as a security scheme - [ ] I think strictness of Security Requirement rules has changed diff --git a/lib/openapi3_parser/node_factory/discriminator.rb b/lib/openapi3_parser/node_factory/discriminator.rb index 8df0d59e..577c275f 100644 --- a/lib/openapi3_parser/node_factory/discriminator.rb +++ b/lib/openapi3_parser/node_factory/discriminator.rb @@ -5,6 +5,8 @@ module Openapi3Parser module NodeFactory class Discriminator < NodeFactory::Object + allow_extensions { |context| context.openapi_version >= "3.1" } + field "propertyName", input_type: String, required: true field "mapping", input_type: Hash, validate: :validate_mapping, diff --git a/lib/openapi3_parser/node_factory/object.rb b/lib/openapi3_parser/node_factory/object.rb index 514808f3..afc2c81a 100644 --- a/lib/openapi3_parser/node_factory/object.rb +++ b/lib/openapi3_parser/node_factory/object.rb @@ -12,6 +12,7 @@ class Object def_delegators "self.class", :field_configs, :extension_regex, + :allowed_extensions?, :mutually_exclusive_fields, :allowed_default?, :validations diff --git a/lib/openapi3_parser/node_factory/object_factory/dsl.rb b/lib/openapi3_parser/node_factory/object_factory/dsl.rb index bb7b39fa..1405a9b4 100644 --- a/lib/openapi3_parser/node_factory/object_factory/dsl.rb +++ b/lib/openapi3_parser/node_factory/object_factory/dsl.rb @@ -17,8 +17,21 @@ def field_configs @field_configs ||= {} end - def allow_extensions(regex: EXTENSION_REGEX) + def allow_extensions(regex: EXTENSION_REGEX, &block) @extension_regex = regex + @allowed_extensions = block || true + end + + def allowed_extensions?(context) + @allowed_extensions ||= nil + + allowed = if @allowed_extensions.respond_to?(:call) + @allowed_extensions.call(context) + else + @allowed_extensions + end + + !!allowed end def extension_regex diff --git a/lib/openapi3_parser/node_factory/object_factory/validator.rb b/lib/openapi3_parser/node_factory/object_factory/validator.rb index 8e0533ca..dd364e09 100644 --- a/lib/openapi3_parser/node_factory/object_factory/validator.rb +++ b/lib/openapi3_parser/node_factory/object_factory/validator.rb @@ -39,9 +39,11 @@ def check_required_fields end def check_unexpected_fields + extension_regex = factory.extension_regex if factory.allowed_extensions?(validatable.context) + Validators::UnexpectedFields.call( validatable, - extension_regex: factory.extension_regex, + extension_regex: extension_regex, allowed_fields: factory.allowed_fields, raise_on_invalid: ) diff --git a/spec/lib/openapi3_parser/node_factory/discriminator_spec.rb b/spec/lib/openapi3_parser/node_factory/discriminator_spec.rb index 13f18024..0d7489d2 100644 --- a/spec/lib/openapi3_parser/node_factory/discriminator_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/discriminator_spec.rb @@ -9,4 +9,32 @@ } end end + + describe "allow extensions" do + it "accepts extensions for OpenAPI 3.1" do + factory_context = create_node_factory_context( + { + "propertyName" => "test", + "x-extension" => "value" + }, + document_input: { "openapi" => "3.1.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).to be_valid + end + + it "rejects extensions for OpenAPI < 3.1" do + factory_context = create_node_factory_context( + { + "propertyName" => "test", + "x-extension" => "value" + }, + document_input: { "openapi" => "3.0.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).not_to be_valid + end + end end From c6b961877dde595b983efbf0b7f6182ccaa0716d Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 9 Jan 2025 21:18:35 +0000 Subject: [PATCH 28/53] Fix rubocop issues from rebase --- lib/openapi3_parser/node/context.rb | 8 ++++---- .../node_factory/object_factory/validator.rb | 2 +- lib/openapi3_parser/node_factory/openapi.rb | 2 +- lib/openapi3_parser/openapi_version.rb | 2 +- spec/lib/openapi3_parser/node/context_spec.rb | 14 +++++++------- .../openapi3_parser/node_factory/context_spec.rb | 2 +- .../object_factory/field_config_spec.rb | 4 ++-- .../object_factory/node_builder_spec.rb | 8 ++++---- .../openapi3_parser/node_factory/object_spec.rb | 2 +- .../node_factory/schema/oas_dialect3_1_spec.rb | 2 +- spec/support/helpers/context.rb | 4 ++-- spec/support/node_equality.rb | 2 +- 12 files changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/openapi3_parser/node/context.rb b/lib/openapi3_parser/node/context.rb index 3affb423..13f29d11 100644 --- a/lib/openapi3_parser/node/context.rb +++ b/lib/openapi3_parser/node/context.rb @@ -27,9 +27,9 @@ def self.root(factory_context) input_locations = input_location?(factory_context.input) ? [source_location] : [] new(factory_context.input, - document_location: document_location, + document_location:, source_locations: [source_location], - input_locations: input_locations) + input_locations:) end # Create a context for the child of a previous context @@ -53,7 +53,7 @@ def self.next_field(parent_context, field, factory_context) new(factory_context.input, document_location:, source_locations: [factory_context.source_location], - input_locations: input_locations) + input_locations:) end # Create a context for a the a field that is the result of a reference @@ -72,7 +72,7 @@ def self.resolved_reference(current_context, reference_factory_context) new(input, document_location: current_context.document_location, source_locations: current_context.source_locations + [reference_factory_context.source_location], - input_locations: input_locations) + input_locations:) end def self.merge_reference_input(current_input, reference_input) diff --git a/lib/openapi3_parser/node_factory/object_factory/validator.rb b/lib/openapi3_parser/node_factory/object_factory/validator.rb index dd364e09..e1a99ba2 100644 --- a/lib/openapi3_parser/node_factory/object_factory/validator.rb +++ b/lib/openapi3_parser/node_factory/object_factory/validator.rb @@ -43,7 +43,7 @@ def check_unexpected_fields Validators::UnexpectedFields.call( validatable, - extension_regex: extension_regex, + extension_regex:, allowed_fields: factory.allowed_fields, raise_on_invalid: ) diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index 44704d5e..6d25b805 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -27,7 +27,7 @@ class Openapi < NodeFactory::Object validate do |validatable| next if validatable.context.openapi_version < "3.1" - next if (validatable.input.keys & %w[components paths webhooks]).any? + next if validatable.input.keys.intersect?(%w[components paths webhooks]) validatable.add_error("At least one of components, paths and webhooks fields are required") end diff --git a/lib/openapi3_parser/openapi_version.rb b/lib/openapi3_parser/openapi_version.rb index e97e4a1e..cf902564 100644 --- a/lib/openapi3_parser/openapi_version.rb +++ b/lib/openapi3_parser/openapi_version.rb @@ -10,7 +10,7 @@ class OpenapiVersion < Gem::Version # # @return [Boolean] def <=>(other) - super self.class.new(other) + super(self.class.new(other)) end end end diff --git a/spec/lib/openapi3_parser/node/context_spec.rb b/spec/lib/openapi3_parser/node/context_spec.rb index b4a9fc1c..2b34ad32 100644 --- a/spec/lib/openapi3_parser/node/context_spec.rb +++ b/spec/lib/openapi3_parser/node/context_spec.rb @@ -179,11 +179,11 @@ it "returns true when input and locations match" do instance = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [source_location], input_locations: [source_location]) other = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [source_location], input_locations: [source_location]) @@ -192,7 +192,7 @@ it "returns false when one of these differ" do instance = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [source_location], input_locations: [source_location]) @@ -203,7 +203,7 @@ ) other = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [other_source_location], input_locations: [other_source_location]) @@ -230,7 +230,7 @@ it "returns true when input and input locations match" do instance = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [source_location], input_locations: [source_location]) other = described_class.new({}, @@ -243,7 +243,7 @@ it "returns false when input doesn't match" do instance = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [source_location], input_locations: [source_location]) other = described_class.new({ different: "data" }, @@ -256,7 +256,7 @@ it "returns false when input locations don't match" do instance = described_class.new({}, - document_location: document_location, + document_location:, source_locations: [source_location], input_locations: [source_location]) other = described_class.new({}, diff --git a/spec/lib/openapi3_parser/node_factory/context_spec.rb b/spec/lib/openapi3_parser/node_factory/context_spec.rb index defb9363..f5981b52 100644 --- a/spec/lib/openapi3_parser/node_factory/context_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/context_spec.rb @@ -130,7 +130,7 @@ } source_location = create_source_location(input) - instance = described_class.new({}, source_location: source_location) + instance = described_class.new({}, source_location:) expect(instance.openapi_version).to eq("3.0") end end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb index 1d454dfb..bd22ec69 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb @@ -75,7 +75,7 @@ def create_contact_validatable(node_factory_context = nil) it "calls the function when a callable is given" do allow(context).to receive(:allowed?).and_return(false) - instance = described_class.new(allowed: ->(context) { context.allowed? }) + instance = described_class.new(allowed: lambda(&:allowed?)) expect(instance.allowed?(context, factory)).to be(false) end @@ -106,7 +106,7 @@ def create_contact_validatable(node_factory_context = nil) it "calls the function when a callable is given" do allow(context).to receive(:required?).and_return(true) - instance = described_class.new(required: ->(context) { context.required? }) + instance = described_class.new(required: lambda(&:required?)) expect(instance.required?(context, factory)).to be(true) end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb index 0494d538..288dccac 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb @@ -157,7 +157,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Referenced", "last_name" => "Smith" }, - document_input: document_input + document_input: ) node_builder = described_class.new( @@ -173,7 +173,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Referenced", "last_name" => nil }, - document_input: document_input + document_input: ) node_builder = described_class.new( @@ -188,7 +188,7 @@ def ref_factory(context) it "returns the data without any $ref fields" do factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Referenced" }, - document_input: document_input + document_input: ) node_builder = described_class.new( @@ -202,7 +202,7 @@ def ref_factory(context) it "raises an error if a reference is broken" do factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Broken" }, - document_input: document_input + document_input: ) node_builder = described_class.new( diff --git a/spec/lib/openapi3_parser/node_factory/object_spec.rb b/spec/lib/openapi3_parser/node_factory/object_spec.rb index e87ca1bf..952b121b 100644 --- a/spec/lib/openapi3_parser/node_factory/object_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_spec.rb @@ -4,7 +4,7 @@ let(:node_factory_context) { create_node_factory_context({}) } let(:instance) { described_class.new(node_factory_context) } - it_behaves_like "node factory", ::Hash + it_behaves_like "node factory", Hash describe "#allowed_fields" do it "returns the keys of fields that are allowed" do diff --git a/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb index ceae6705..034a30d8 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb @@ -40,7 +40,7 @@ end let(:node_factory_context) do - create_node_factory_context(input, document_input: document_input) + create_node_factory_context(input, document_input:) end let(:node_context) do diff --git a/spec/support/helpers/context.rb b/spec/support/helpers/context.rb index 74fe2452..cef21d24 100644 --- a/spec/support/helpers/context.rb +++ b/spec/support/helpers/context.rb @@ -36,7 +36,7 @@ def node_factory_context_to_node_context(node_factory_context) Openapi3Parser::Node::Context.new(input, document_location: source_location, source_locations: [source_location], - input_locations: input_locations) + input_locations:) end def create_node_context(input, document_input: {}, pointer_segments: []) @@ -48,7 +48,7 @@ def create_node_context(input, document_input: {}, pointer_segments: []) Openapi3Parser::Node::Context.new(input, document_location: location, source_locations: [location], - input_locations: input_locations) + input_locations:) end end end diff --git a/spec/support/node_equality.rb b/spec/support/node_equality.rb index 60e3763f..da57dd1c 100644 --- a/spec/support/node_equality.rb +++ b/spec/support/node_equality.rb @@ -41,7 +41,7 @@ context.document_location.source, %w[option_b] ), - source_locations: source_locations, + source_locations:, input_locations: source_locations ) From 4c60fbe7c73c7aefd1d1efa8b81e4f272701ac5a Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 9 Jan 2025 21:18:51 +0000 Subject: [PATCH 29/53] Add explicit require for node_factory This resolves an error that is caused by the removal of `#sort` from Dir.glob. Dir.glob produces a slightly different file ordering without sort and thus causes the below missing constant error. ``` Failure/Error: def allow_extensions(regex: EXTENSION_REGEX, &block) @extension_regex = regex @allowed_extensions = block || true end NameError: uninitialized constant Openapi3Parser::NodeFactory::ObjectFactory::Dsl::EXTENSION_REGEX def allow_extensions(regex: EXTENSION_REGEX, &block) ``` --- lib/openapi3_parser/node_factory/object_factory/dsl.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/openapi3_parser/node_factory/object_factory/dsl.rb b/lib/openapi3_parser/node_factory/object_factory/dsl.rb index 1405a9b4..f218857b 100644 --- a/lib/openapi3_parser/node_factory/object_factory/dsl.rb +++ b/lib/openapi3_parser/node_factory/object_factory/dsl.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "openapi3_parser/node_factory" require "openapi3_parser/node_factory/object_factory/field_config" module Openapi3Parser From ab44778951ae2e778fd4cc716fd0a86d0138772b Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 10 Jan 2025 15:57:45 +0000 Subject: [PATCH 30/53] Share behaviour between schema objects This sets up the ability for shared code between Schema objects and providing customisation for ones of different OpenAPI versions. It marks a direction towards modelling the specification of JSON Schema 2020-12 JSON Schema Validation [1] specifically rather trying to understand how different dialects could be used. My expectation is that it is very much an edge case that someone would use another dialect and it seems incredibly hard to consider how they can be supported. There's likely some optimising that could be done of the shared example specs, but I wanted to get something together on the faster side. This also has a rename of Schema::OasDialect3_1 to Schema::V3_1. I don't think we'll ever try to model other dialects so I think it's best to have a single class to try model the schemas for 3.1 and hopefully later versions. [1]: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 WIP --- json-schema-for-3.1.md | 16 +- lib/openapi3_parser/node/schema.rb | 244 +++++++++++++++++- .../node/schema/oas_dialect3_1.rb | 12 - lib/openapi3_parser/node/schema/v3_0.rb | 243 +---------------- lib/openapi3_parser/node/schema/v3_1.rb | 21 ++ lib/openapi3_parser/node_factory/schema.rb | 2 +- .../node_factory/schema/common.rb | 134 ++++++++++ .../node_factory/schema/v3_0.rb | 116 +-------- .../schema/{oas_dialect3_1.rb => v3_1.rb} | 14 +- .../openapi3_parser/node/schema/v3_0_spec.rb | 88 +------ .../openapi3_parser/node/schema/v3_1_spec.rb | 5 + .../resolved_input_builder_spec.rb | 2 +- .../node_factory/schema/v3_0_spec.rb | 142 +--------- .../{oas_dialect3_1_spec.rb => v3_1_spec.rb} | 6 +- spec/support/examples/v3.1/changes.yaml | 20 +- spec/support/schema_common.rb | 243 +++++++++++++++++ 16 files changed, 689 insertions(+), 619 deletions(-) delete mode 100644 lib/openapi3_parser/node/schema/oas_dialect3_1.rb create mode 100644 lib/openapi3_parser/node/schema/v3_1.rb create mode 100644 lib/openapi3_parser/node_factory/schema/common.rb rename lib/openapi3_parser/node_factory/schema/{oas_dialect3_1.rb => v3_1.rb} (64%) create mode 100644 spec/lib/openapi3_parser/node/schema/v3_1_spec.rb rename spec/lib/openapi3_parser/node_factory/schema/{oas_dialect3_1_spec.rb => v3_1_spec.rb} (91%) create mode 100644 spec/support/schema_common.rb diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index e53c2532..b7fa914d 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -6,7 +6,7 @@ Things have got complex with schemas in OpenAPI 3.1 How things might work: -- when a schema factory is created, it determines whether the dialect is suported +- when a schema factory is created, it determines whether the dialect is supported - it then creates a factory based on the dialect - if there is a reference in it this is resolved - there could be complexities in the resolving process because of the id field - does it become relative to this? @@ -90,3 +90,17 @@ additionalProperties: single json schema unevaluatedItems - single schema unevaluatedProperties: single schema + + +## Returning to this in 2025 + +Assumption: it'll be extremely rare for usage of the advanced schema fields like dynamicRefs and dynamicAnchors, let's see what we can implement that meets most use cases and hopefully doesn't crash on complex ones + +Current idea is create a Schema::Common which can share methods between both schema objects that are shared, then add distinctions for differences + +At point of shutting down on 10th January 2025 I was wondering about how schemas merge. I also decided to defer thinking about referenceable node object factory. + +I learnt that merging seems largely undefined in JSON Schema, as far as I can tell and I'm just going with a strategy of most recent field wins. + +I've set up a Node::Schema class for common schema methods and Node::Schema::v3_0 and v3_1Up classes for specific changes. Need to flesh out +tests and then behaviour that differs between them diff --git a/lib/openapi3_parser/node/schema.rb b/lib/openapi3_parser/node/schema.rb index 371bc45c..9732825d 100644 --- a/lib/openapi3_parser/node/schema.rb +++ b/lib/openapi3_parser/node/schema.rb @@ -1,8 +1,250 @@ # frozen_string_literal: true +require "openapi3_parser/node/object" + module Openapi3Parser module Node - module Schema + # Base class for common behaviour between Schema objects of different + # OpenAPI versions. It is expected to be treated as an abstract class + # + # rubocop:disable Metrics/ClassLength + class Schema < Node::Object + # This is used to provide a name for the schema based on it's position in + # an OpenAPI document. + # + # For example it's common to have an OpenAPI document structured like so: + # components: + # schemas: + # Product: + # properties: + # product_id: + # type: string + # description: + # type: string + # + # and there is then implied meaning in the field name of Product, ie + # that schema now represents a product. This data is not easily or + # consistently made available as it is part of the path to the data + # rather than the data itself. Instead the field that would be more + # appropriate would be "title" within a schema. + # + # As this is a common pattern in OpenAPI docs this provides a method + # to look up this contextual name of the schema so it can be referenced + # when working with the document, it only considers a field to be + # name if it is within a group called schemas (as is the case + # in #/components/schemas) + # + # @return [String, nil] + def name + segments = node_context.source_locations.first.pointer.segments + segments[-1] if segments[-2] == "schemas" + end + + # @return [String, nil] + def title + self["title"] + end + + # @return [Numeric, nil] + def multiple_of + self["multipleOf"] + end + + # @return [Integer, nil] + def maximum + self["maximum"] + end + + # @return [Boolean] + def exclusive_maximum? + self["exclusiveMaximum"] + end + + # @return [Integer, nil] + def minimum + self["minimum"] + end + + # @return [Boolean] + def exclusive_minimum? + self["exclusiveMinimum"] + end + + # @return [Integer, nil] + def max_length + self["maxLength"] + end + + # @return [Integer] + def min_length + self["minLength"] + end + + # @return [String, nil] + def pattern + self["pattern"] + end + + # @return [Integer, nil] + def max_items + self["maxItems"] + end + + # @return [Integer] + def min_items + self["minItems"] + end + + # @return [Boolean] + def unique_items? + self["uniqueItems"] + end + + # @return [Integer, nil] + def max_properties + self["maxProperties"] + end + + # @return [Integer] + def min_properties + self["minProperties"] + end + + # @return [Node::Array, nil] + def required + self["required"] + end + + # Returns whether a property is a required field or not. Can accept the + # property name or a schema + # + # @param [String, Schema] property + # @return [Boolean] + def requires?(property) + if property.is_a?(self.class) + # compare node_context of objects to ensure references aren't treated + # as equal - only direct properties of this object will pass. + properties.to_h + .slice(*required.to_a) + .any? { |_, schema| schema.node_context == property.node_context } + else + required.to_a.include?(property) + end + end + + # @return [Node::Array, nil] + def enum + self["enum"] + end + + # @return [String, nil] + def type + self["type"] + end + + # @return [Node::Array, nil] + def all_of + self["allOf"] + end + + # @return [Node::Array, nil] + def one_of + self["oneOf"] + end + + # @return [Node::Array, nil] + def any_of + self["anyOf"] + end + + # @return [Schema, nil] + def not + self["not"] + end + + # @return [Schema, nil] + def items + self["items"] + end + + # @return [Map] + def properties + self["properties"] + end + + # @return [Boolean] + def additional_properties? + self["additionalProperties"] != false + end + + # @return [Schema, nil] + def additional_properties_schema + properties = self["additionalProperties"] + return if [true, false].include?(properties) + + properties + end + + # @return [String, nil] + def description + self["description"] + end + + # @return [String, nil] + def description_html + render_markdown(description) + end + + # @return [String, nil] + def format + self["format"] + end + + # @return [Any] + def default + self["default"] + end + + # @return [Boolean] + def nullable? + self["nullable"] + end + + # @return [Discriminator, nil] + def discriminator + self["discriminator"] + end + + # @return [Boolean] + def read_only? + self["readOnly"] + end + + # @return [Boolean] + def write_only? + self["writeOnly"] + end + + # @return [Xml, nil] + def xml + self["xml"] + end + + # @return [ExternalDocumentation, nil] + def external_docs + self["externalDocs"] + end + + # @return [Any] + def example + self["example"] + end + + # @return [Boolean] + def deprecated? + self["deprecated"] + end end + # rubocop:enable Metrics/ClassLength end end diff --git a/lib/openapi3_parser/node/schema/oas_dialect3_1.rb b/lib/openapi3_parser/node/schema/oas_dialect3_1.rb deleted file mode 100644 index 19b8f7fa..00000000 --- a/lib/openapi3_parser/node/schema/oas_dialect3_1.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -require "openapi3_parser/node/object" - -module Openapi3Parser - module Node - module Schema - class OasDialect3_1 < Node::Object # rubocop:disable Naming/ClassAndModuleCamelCase - end - end - end -end diff --git a/lib/openapi3_parser/node/schema/v3_0.rb b/lib/openapi3_parser/node/schema/v3_0.rb index 79a71f23..20795005 100644 --- a/lib/openapi3_parser/node/schema/v3_0.rb +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -1,250 +1,13 @@ # frozen_string_literal: true -require "openapi3_parser/node/object" +require "openapi3_parser/node/schema" module Openapi3Parser module Node - module Schema + class Schema < Node::Object # @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject - # rubocop:disable Metrics/ClassLength - class V3_0 < Node::Object # rubocop:disable Naming/ClassAndModuleCamelCase - # This is used to provide a name for the schema based on it's position in - # an OpenAPI document. - # - # For example it's common to have an OpenAPI document structured like so: - # components: - # schemas: - # Product: - # properties: - # product_id: - # type: string - # description: - # type: string - # - # and there is then implied meaning in the field name of Product, ie - # that schema now represents a product. This data is not easily or - # consistently made available as it is part of the path to the data - # rather than the data itself. Instead the field that would be more - # appropriate would be "title" within a schema. - # - # As this is a common pattern in OpenAPI docs this provides a method - # to look up this contextual name of the schema so it can be referenced - # when working with the document, it only considers a field to be - # name if it is within a group called schemas (as is the case - # in #/components/schemas) - # - # @return [String, nil] - def name - segments = node_context.source_locations.first.pointer.segments - segments[-1] if segments[-2] == "schemas" - end - - # @return [String, nil] - def title - self["title"] - end - - # @return [Numeric, nil] - def multiple_of - self["multipleOf"] - end - - # @return [Integer, nil] - def maximum - self["maximum"] - end - - # @return [Boolean] - def exclusive_maximum? - self["exclusiveMaximum"] - end - - # @return [Integer, nil] - def minimum - self["minimum"] - end - - # @return [Boolean] - def exclusive_minimum? - self["exclusiveMinimum"] - end - - # @return [Integer, nil] - def max_length - self["maxLength"] - end - - # @return [Integer] - def min_length - self["minLength"] - end - - # @return [String, nil] - def pattern - self["pattern"] - end - - # @return [Integer, nil] - def max_items - self["maxItems"] - end - - # @return [Integer] - def min_items - self["minItems"] - end - - # @return [Boolean] - def unique_items? - self["uniqueItems"] - end - - # @return [Integer, nil] - def max_properties - self["maxProperties"] - end - - # @return [Integer] - def min_properties - self["minProperties"] - end - - # @return [Node::Array, nil] - def required - self["required"] - end - - # Returns whether a property is a required field or not. Can accept the - # property name or a schema - # - # @param [String, Schema] property - # @return [Boolean] - def requires?(property) - if property.is_a?(self.class) - # compare node_context of objects to ensure references aren't treated - # as equal - only direct properties of this object will pass. - properties.to_h - .slice(*required.to_a) - .any? { |_, schema| schema.node_context == property.node_context } - else - required.to_a.include?(property) - end - end - - # @return [Node::Array, nil] - def enum - self["enum"] - end - - # @return [String, nil] - def type - self["type"] - end - - # @return [Node::Array, nil] - def all_of - self["allOf"] - end - - # @return [Node::Array, nil] - def one_of - self["oneOf"] - end - - # @return [Node::Array, nil] - def any_of - self["anyOf"] - end - - # @return [Schema, nil] - def not - self["not"] - end - - # @return [Schema, nil] - def items - self["items"] - end - - # @return [Map] - def properties - self["properties"] - end - - # @return [Boolean] - def additional_properties? - self["additionalProperties"] != false - end - - # @return [Schema, nil] - def additional_properties_schema - properties = self["additionalProperties"] - return if [true, false].include?(properties) - - properties - end - - # @return [String, nil] - def description - self["description"] - end - - # @return [String, nil] - def description_html - render_markdown(description) - end - - # @return [String, nil] - def format - self["format"] - end - - # @return [Any] - def default - self["default"] - end - - # @return [Boolean] - def nullable? - self["nullable"] - end - - # @return [Discriminator, nil] - def discriminator - self["discriminator"] - end - - # @return [Boolean] - def read_only? - self["readOnly"] - end - - # @return [Boolean] - def write_only? - self["writeOnly"] - end - - # @return [Xml, nil] - def xml - self["xml"] - end - - # @return [ExternalDocumentation, nil] - def external_docs - self["externalDocs"] - end - - # @return [Any] - def example - self["example"] - end - - # @return [Boolean] - def deprecated? - self["deprecated"] - end + class V3_0 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase end - # rubocop:enable Metrics/ClassLength end end end diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb new file mode 100644 index 00000000..e2310c2a --- /dev/null +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "openapi3_parser/node/schema" + +module Openapi3Parser + module Node + class Schema < Node::Object + # @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#schemaObject + # + # With OpenAPI 3.1 Schemas are no longer defined as an OpenAPI object and + # instead use the JSON Schema 2020-12 specification. + # + # The JSON Schema definition is rather complex with the ability to specify + # different dialects and dynamic references, this doesn't attempt to model + # these complexities and focuses on the core schema as defined in: + # https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 + class V3_1 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/schema.rb b/lib/openapi3_parser/node_factory/schema.rb index 0c4f66d6..d4b73355 100644 --- a/lib/openapi3_parser/node_factory/schema.rb +++ b/lib/openapi3_parser/node_factory/schema.rb @@ -5,7 +5,7 @@ module NodeFactory module Schema def self.factory(context) if context.openapi_version >= "3.1" - OasDialect3_1 + V3_1 else NodeFactory::OptionalReference.new(V3_0) end diff --git a/lib/openapi3_parser/node_factory/schema/common.rb b/lib/openapi3_parser/node_factory/schema/common.rb new file mode 100644 index 00000000..7d062207 --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/common.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "openapi3_parser/node_factory/object" + +module Openapi3Parser + module NodeFactory + module Schema + # This module contains methods and configuration that are consistent + # across all schema node factories and mixed into them. + module Common + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def self.included(base) + base.field "title", input_type: String + + base.field "multipleOf", input_type: Numeric + base.field "maximum", input_type: Integer + base.field "exclusiveMaximum", input_type: :boolean, default: false + base.field "minimum", input_type: Integer + base.field "exclusiveMinimum", input_type: :boolean, default: false + base.field "maxLength", input_type: Integer + base.field "minLength", input_type: Integer, default: 0 + base.field "pattern", input_type: String + base.field "maxItems", input_type: Integer + base.field "minItems", input_type: Integer, default: 0 + base.field "uniqueItems", input_type: :boolean, default: false + base.field "maxProperties", input_type: Integer + base.field "minProperties", input_type: Integer, default: 0 + base.field "required", factory: :required_factory + base.field "enum", factory: :enum_factory + + base.field "allOf", factory: :referenceable_schema_array + base.field "oneOf", factory: :referenceable_schema_array + base.field "anyOf", factory: :referenceable_schema_array + base.field "not", factory: :referenceable_schema + base.field "items", factory: :referenceable_schema + base.field "properties", factory: :properties_factory + base.field "additionalProperties", + validate: :additional_properties_input_type, + factory: :additional_properties_factory, + default: false + base.field "description", input_type: String + base.field "format", input_type: String + base.field "default" + + base.field "nullable", input_type: :boolean, default: false + base.field "discriminator", factory: :discriminator_factory + base.field "readOnly", input_type: :boolean, default: false + base.field "writeOnly", input_type: :boolean, default: false + base.field "xml", factory: :xml_factory + base.field "externalDocs", factory: :external_docs_factory + base.field "example" + base.field "deprecated", input_type: :boolean, default: false + + base.validate :items_for_array, :read_only_or_write_only + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + + private + + def items_for_array(validatable) + return unless validatable.input["type"] == "array" + return unless validatable.factory.resolved_input["items"].nil? + + validatable.add_error("items must be defined for a type of array") + end + + def read_only_or_write_only(validatable) + input = validatable.input + return if [input["readOnly"], input["writeOnly"]].uniq != [true] + + validatable.add_error("readOnly and writeOnly cannot both be true") + end + + def required_factory(context) + NodeFactory::Array.new( + context, + default: nil, + value_input_type: String + ) + end + + def enum_factory(context) + NodeFactory::Array.new(context, default: nil) + end + + def discriminator_factory(context) + NodeFactory::Discriminator.new(context) + end + + def xml_factory(context) + NodeFactory::Xml.new(context) + end + + def external_docs_factory(context) + NodeFactory::ExternalDocumentation.new(context) + end + + def properties_factory(context) + NodeFactory::Map.new( + context, + value_factory: NodeFactory::Schema.factory(context) + ) + end + + def referenceable_schema(context) + NodeFactory::Schema.build_factory(context) + end + + def referenceable_schema_array(context) + NodeFactory::Array.new( + context, + default: nil, + value_factory: NodeFactory::Schema.factory(context) + ) + end + + def additional_properties_input_type(validatable) + input = validatable.input + return if [true, false].include?(input) || input.is_a?(Hash) + + validatable.add_error("Expected a Boolean or an Object") + end + + def additional_properties_factory(context) + return context.input if [true, false].include?(context.input) + + referenceable_schema(context) + end + end + end + end +end diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb index 4a6dcac5..243d2248 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_0.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -6,125 +6,15 @@ module Openapi3Parser module NodeFactory module Schema class V3_0 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase - allow_extensions - field "title", input_type: String - field "multipleOf", input_type: Numeric - field "maximum", input_type: Integer - field "exclusiveMaximum", input_type: :boolean, default: false - field "minimum", input_type: Integer - field "exclusiveMinimum", input_type: :boolean, default: false - field "maxLength", input_type: Integer - field "minLength", input_type: Integer, default: 0 - field "pattern", input_type: String - field "maxItems", input_type: Integer - field "minItems", input_type: Integer, default: 0 - field "uniqueItems", input_type: :boolean, default: false - field "maxProperties", input_type: Integer - field "minProperties", input_type: Integer, default: 0 - field "required", factory: :required_factory - field "enum", factory: :enum_factory + include Schema::Common + allow_extensions + # OpenAPI 3.0 requires a type of String, whereas 3.1 up are String or Array field "type", input_type: String - field "allOf", factory: :referenceable_schema_array - field "oneOf", factory: :referenceable_schema_array - field "anyOf", factory: :referenceable_schema_array - field "not", factory: :referenceable_schema - field "items", factory: :referenceable_schema - field "properties", factory: :properties_factory - field "additionalProperties", - validate: :additional_properties_input_type, - factory: :additional_properties_factory, - default: false - field "description", input_type: String - field "format", input_type: String - field "default" - - field "nullable", input_type: :boolean, default: false - field "discriminator", factory: :discriminator_factory - field "readOnly", input_type: :boolean, default: false - field "writeOnly", input_type: :boolean, default: false - field "xml", factory: :xml_factory - field "externalDocs", factory: :external_docs_factory - field "example" - field "deprecated", input_type: :boolean, default: false - - validate :items_for_array, :read_only_or_write_only def build_node(data, node_context) Node::Schema::V3_0.new(data, node_context) end - - private - - def items_for_array(validatable) - return unless validatable.input["type"] == "array" - return unless validatable.factory.resolved_input["items"].nil? - - validatable.add_error("items must be defined for a type of array") - end - - def read_only_or_write_only(validatable) - input = validatable.input - return if [input["readOnly"], input["writeOnly"]].uniq != [true] - - validatable.add_error("readOnly and writeOnly cannot both be true") - end - - def required_factory(context) - NodeFactory::Array.new( - context, - default: nil, - value_input_type: String - ) - end - - def enum_factory(context) - NodeFactory::Array.new(context, default: nil) - end - - def discriminator_factory(context) - NodeFactory::Discriminator.new(context) - end - - def xml_factory(context) - NodeFactory::Xml.new(context) - end - - def external_docs_factory(context) - NodeFactory::ExternalDocumentation.new(context) - end - - def properties_factory(context) - NodeFactory::Map.new( - context, - value_factory: NodeFactory::Schema.factory(context) - ) - end - - def referenceable_schema(context) - NodeFactory::Schema.build_factory(context) - end - - def referenceable_schema_array(context) - NodeFactory::Array.new( - context, - default: nil, - value_factory: NodeFactory::Schema.factory(context) - ) - end - - def additional_properties_input_type(validatable) - input = validatable.input - return if [true, false].include?(input) || input.is_a?(Hash) - - validatable.add_error("Expected a Boolean or an Object") - end - - def additional_properties_factory(context) - return context.input if [true, false].include?(context.input) - - referenceable_schema(context) - end end end end diff --git a/lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb similarity index 64% rename from lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb rename to lib/openapi3_parser/node_factory/schema/v3_1.rb index 968673aa..b8bd4a55 100644 --- a/lib/openapi3_parser/node_factory/schema/oas_dialect3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -6,17 +6,18 @@ module Openapi3Parser module NodeFactory module Schema - class OasDialect3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase + class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase include Referenceable + include Schema::Common + # 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 "properties", factory: :properties_factory def build_node(data, node_context) - Node::Schema::OasDialect3_1.new(data, node_context) + Node::Schema::V3_1.new(data, node_context) end private @@ -24,13 +25,6 @@ def build_node(data, node_context) def ref_factory(context) NodeFactory::Fields::Reference.new(context, self.class) end - - def properties_factory(context) - NodeFactory::Map.new( - context, - value_factory: NodeFactory::Schema.factory(context) - ) - end end end end diff --git a/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb index 9a92b29c..bd868171 100644 --- a/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb +++ b/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb @@ -1,91 +1,5 @@ # frozen_string_literal: true RSpec.describe Openapi3Parser::Node::Schema::V3_0 do - describe "#name" do - it "returns the key of the context when the item is defined within components/schemas" do - node_context = create_node_context( - {}, - pointer_segments: %w[components schemas Pet] - ) - instance = described_class.new({}, node_context) - expect(instance.name).to eq "Pet" - end - - it "returns nil when a schema is defined outside of components/schemas" do - node_context = create_node_context( - {}, - pointer_segments: %w[content application/json schema] - ) - instance = described_class.new({}, node_context) - expect(instance.name).to be_nil - end - end - - describe "#requires?" do - let(:node) do - input = { - "type" => "object", - "required" => %w[field_a], - "properties" => { - "field_a" => { "type" => "string" }, - "field_b" => { "type" => "string" } - } - } - - factory_context = create_node_factory_context(input) - Openapi3Parser::NodeFactory::Schema::V3_0 - .new(factory_context) - .node(node_factory_context_to_node_context(factory_context)) - end - - context "when enquiring with a field name" do - it "returns true when a field name is required" do - expect(node.requires?("field_a")).to be true - end - - it "returns false when a field name is not required" do - expect(node.requires?("field_b")).to be false - end - end - - context "when enquiring with a schema object" do - it "returns true when the schema is required" do - expect(node.requires?(node.properties["field_a"])).to be true - end - - it "returns false when the schema is not required" do - expect(node.requires?(node.properties["field_b"])).to be false - end - end - - context "when comparing referenced schemas" do - let(:node) do - input = { - "type" => "object", - "required" => %w[field_a], - "properties" => { - "field_a" => { "$ref" => "#/referenced_item" }, - "field_b" => { "$ref" => "#/referenced_item" } - } - } - - document_input = { - "referenced_item" => { "type" => "string" } - } - - factory_context = create_node_factory_context(input, document_input:) - Openapi3Parser::NodeFactory::Schema::V3_0 - .new(factory_context) - .node(node_factory_context_to_node_context(factory_context)) - end - - it "returns true for the required reference field" do - expect(node.requires?(node.properties["field_a"])).to be true - end - - it "returns false for the reference field that isn't required" do - expect(node.requires?(node.properties["field_b"])).to be false - end - end - end + it_behaves_like "schema node", openapi_version: "3.0.0" end diff --git a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb new file mode 100644 index 00000000..7c0427d6 --- /dev/null +++ b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::Node::Schema::V3_1 do + it_behaves_like "schema node", openapi_version: "3.1.0" +end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb index a00b3686..88aeada9 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb @@ -103,7 +103,7 @@ def ref_factory(context) } ) - factory = Openapi3Parser::NodeFactory::Schema::OasDialect3_1.new(factory_context) + factory = Openapi3Parser::NodeFactory::Schema::V3_1.new(factory_context) expect(described_class.call(factory)).to match( { diff --git a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb index c23eb9ad..f028fa50 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb @@ -45,145 +45,5 @@ end end - it_behaves_like "default field", field: "nullable", defaults_to: false do - let(:node_factory_context) do - create_node_factory_context({ "nullable" => nullable }) - end - - let(:node_context) do - node_factory_context_to_node_context(node_factory_context) - end - end - - it_behaves_like "default field", - field: "readOnly", - defaults_to: false, - var_name: :read_only do - let(:node_factory_context) do - create_node_factory_context({ "readOnly" => read_only }) - end - - let(:node_context) do - node_factory_context_to_node_context(node_factory_context) - end - end - - it_behaves_like "default field", - field: "writeOnly", - defaults_to: false, - var_name: :write_only do - let(:node_factory_context) do - create_node_factory_context({ "writeOnly" => write_only }) - end - - let(:node_context) do - node_factory_context_to_node_context(node_factory_context) - end - end - - it_behaves_like "default field", - field: "deprecated", - defaults_to: false, - var_name: :deprecated do - let(:node_factory_context) do - create_node_factory_context({ "deprecated" => deprecated }) - end - - let(:node_context) do - node_factory_context_to_node_context(node_factory_context) - end - end - - describe "validating items" do - it "is valid when type is 'array' and items are provided" do - instance = described_class.new( - create_node_factory_context({ "type" => "array", "items" => { "type" => "string" } }) - ) - expect(instance).to be_valid - end - - it "is valid when type isn't 'array' and items aren't provided" do - instance = described_class.new( - create_node_factory_context({ "type" => "string" }) - ) - expect(instance).to be_valid - end - - it "is invalid when type is 'array' and items aren't provided" do - instance = described_class.new( - create_node_factory_context({ "type" => "array" }) - ) - expect(instance).not_to be_valid - expect(instance) - .to have_validation_error("#/") - .with_message("items must be defined for a type of array") - end - end - - describe "default field" do - it "supports a default field of false" do - node_factory_context = create_node_factory_context({ "default" => false }) - node_context = node_factory_context_to_node_context(node_factory_context) - - instance = described_class.new(node_factory_context) - - expect(instance).to be_valid - expect(instance.node(node_context).default).to be(false) - end - end - - describe "validating writeOnly and readOnly" do - it "is invalid when both writeOnly and readOnly are true" do - instance = described_class.new( - create_node_factory_context({ "writeOnly" => true, "readOnly" => true }) - ) - expect(instance).not_to be_valid - expect(instance) - .to have_validation_error("#/") - .with_message("readOnly and writeOnly cannot both be true") - end - - it "is valid when one of writeOnly and readOnly are true" do - write_only = described_class.new( - create_node_factory_context({ "writeOnly" => true }) - ) - expect(write_only).to be_valid - - read_only = described_class.new( - create_node_factory_context({ "readOnly" => true }) - ) - expect(read_only).to be_valid - end - end - - describe "validating additionalProperties" do - it "is valid for a boolean" do - true_instance = described_class.new( - create_node_factory_context({ "additionalProperties" => true }) - ) - expect(true_instance).to be_valid - - false_instance = described_class.new( - create_node_factory_context({ "additionalProperties" => false }) - ) - expect(false_instance).to be_valid - end - - it "is valid for a schema" do - instance = described_class.new( - create_node_factory_context({ "additionalProperties" => { "type" => "object" } }) - ) - expect(instance).to be_valid - end - - it "is invalid for something different" do - instance = described_class.new( - create_node_factory_context({ "additionalProperties" => %w[item1 item2] }) - ) - expect(instance).not_to be_valid - expect(instance) - .to have_validation_error("#/additionalProperties") - .with_message("Expected a Boolean or an Object") - end - end + it_behaves_like "schema factory" end diff --git a/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb similarity index 91% rename from spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb rename to spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb index 034a30d8..83d31182 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/oas_dialect3_1_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe Openapi3Parser::NodeFactory::Schema::OasDialect3_1 do +RSpec.describe Openapi3Parser::NodeFactory::Schema::V3_1 do # TODO: perhaps a behaves like referenceable node object factory? # Basic c+p of V3_0 Schema test for now - it_behaves_like "node object factory", Openapi3Parser::Node::Schema::OasDialect3_1 do + it_behaves_like "node object factory", Openapi3Parser::Node::Schema::V3_1 do let(:input) do { "allOf" => [ @@ -47,4 +47,6 @@ node_factory_context_to_node_context(node_factory_context) end end + + it_behaves_like "schema factory" end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index ff92f785..2a31b879 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -49,15 +49,15 @@ components: properties: recursive_item: $ref: "#/components/schemas/SelfReferencingSchema" - # WithDialect: - # $schema: https://spec.openapis.org/oas/3.1/dialect/base - # type: string - # Const: - # const: "test" - # MultipleTypes: - # type: - # - string - # - null + WithDialect: + $schema: https://spec.openapis.org/oas/3.1/dialect/base + type: string + Const: + const: "test" + MultipleTypes: + type: + - string + - null # Number: # type: integer # multipleOf: 5 @@ -81,7 +81,7 @@ components: # maxContains: 1 # prefixItems: # - const: "item" - # type: string + # type: string # items: # type: string # unevaluatedItems: diff --git a/spec/support/schema_common.rb b/spec/support/schema_common.rb new file mode 100644 index 00000000..fd457ecf --- /dev/null +++ b/spec/support/schema_common.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +# This file contains shared examples that can be used on the schema node_factory +# and schema node classes for common functionality. + +RSpec.shared_examples "schema factory" do + it_behaves_like "default field", field: "nullable", defaults_to: false do + let(:node_factory_context) do + create_node_factory_context({ "nullable" => nullable }) + end + + let(:node_context) do + node_factory_context_to_node_context(node_factory_context) + end + end + + it_behaves_like "default field", + field: "readOnly", + defaults_to: false, + var_name: :read_only do + let(:node_factory_context) do + create_node_factory_context({ "readOnly" => read_only }) + end + + let(:node_context) do + node_factory_context_to_node_context(node_factory_context) + end + end + + it_behaves_like "default field", + field: "writeOnly", + defaults_to: false, + var_name: :write_only do + let(:node_factory_context) do + create_node_factory_context({ "writeOnly" => write_only }) + end + + let(:node_context) do + node_factory_context_to_node_context(node_factory_context) + end + end + + it_behaves_like "default field", + field: "deprecated", + defaults_to: false, + var_name: :deprecated do + let(:node_factory_context) do + create_node_factory_context({ "deprecated" => deprecated }) + end + + let(:node_context) do + node_factory_context_to_node_context(node_factory_context) + end + end + + describe "validating items" do + it "is valid when type is 'array' and items are provided" do + instance = described_class.new( + create_node_factory_context({ "type" => "array", "items" => { "type" => "string" } }) + ) + expect(instance).to be_valid + end + + it "is valid when type isn't 'array' and items aren't provided" do + instance = described_class.new( + create_node_factory_context({ "type" => "string" }) + ) + expect(instance).to be_valid + end + + it "is invalid when type is 'array' and items aren't provided" do + instance = described_class.new( + create_node_factory_context({ "type" => "array" }) + ) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/") + .with_message("items must be defined for a type of array") + end + end + + describe "default field" do + it "supports a default field of false" do + node_factory_context = create_node_factory_context({ "default" => false }) + node_context = node_factory_context_to_node_context(node_factory_context) + + instance = described_class.new(node_factory_context) + + expect(instance).to be_valid + expect(instance.node(node_context).default).to be(false) + end + end + + describe "validating writeOnly and readOnly" do + it "is invalid when both writeOnly and readOnly are true" do + instance = described_class.new( + create_node_factory_context({ "writeOnly" => true, "readOnly" => true }) + ) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/") + .with_message("readOnly and writeOnly cannot both be true") + end + + it "is valid when one of writeOnly and readOnly are true" do + write_only = described_class.new( + create_node_factory_context({ "writeOnly" => true }) + ) + expect(write_only).to be_valid + + read_only = described_class.new( + create_node_factory_context({ "readOnly" => true }) + ) + expect(read_only).to be_valid + end + end + + describe "validating additionalProperties" do + it "is valid for a boolean" do + true_instance = described_class.new( + create_node_factory_context({ "additionalProperties" => true }) + ) + expect(true_instance).to be_valid + + false_instance = described_class.new( + create_node_factory_context({ "additionalProperties" => false }) + ) + expect(false_instance).to be_valid + end + + it "is valid for a schema" do + instance = described_class.new( + create_node_factory_context({ "additionalProperties" => { "type" => "object" } }) + ) + expect(instance).to be_valid + end + + it "is invalid for something different" do + instance = described_class.new( + create_node_factory_context({ "additionalProperties" => %w[item1 item2] }) + ) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/additionalProperties") + .with_message("Expected a Boolean or an Object") + end + end +end + +RSpec.shared_examples "schema node" do |openapi_version:| + describe "#name" do + it "returns the key of the context when the item is defined within components/schemas" do + node_context = create_node_context( + {}, + pointer_segments: %w[components schemas Pet] + ) + instance = described_class.new({}, node_context) + expect(instance.name).to eq "Pet" + end + + it "returns nil when a schema is defined outside of components/schemas" do + node_context = create_node_context( + {}, + pointer_segments: %w[content application/json schema] + ) + instance = described_class.new({}, node_context) + expect(instance.name).to be_nil + end + end + + describe "#requires?" do + let(:node) do + input = { + "type" => "object", + "required" => %w[field_a], + "properties" => { + "field_a" => { "type" => "string" }, + "field_b" => { "type" => "string" } + } + } + + document_input = { + "openapi" => openapi_version + } + + factory_context = create_node_factory_context(input, document_input:) + Openapi3Parser::NodeFactory::Schema + .build_factory(factory_context) + .node(node_factory_context_to_node_context(factory_context)) + end + + context "when enquiring with a field name" do + it "returns true when a field name is required" do + expect(node.requires?("field_a")).to be true + end + + it "returns false when a field name is not required" do + expect(node.requires?("field_b")).to be false + end + end + + context "when enquiring with a schema object" do + it "returns true when the schema is required" do + expect(node.requires?(node.properties["field_a"])).to be true + end + + it "returns false when the schema is not required" do + expect(node.requires?(node.properties["field_b"])).to be false + end + end + + context "when comparing referenced schemas" do + let(:node) do + input = { + "type" => "object", + "required" => %w[field_a], + "properties" => { + "field_a" => { "$ref" => "#/referenced_item" }, + "field_b" => { "$ref" => "#/referenced_item" } + } + } + + document_input = { + "openapi" => openapi_version, + "referenced_item" => { "type" => "string" } + } + + factory_context = create_node_factory_context(input, document_input:) + Openapi3Parser::NodeFactory::Schema + .build_factory(factory_context) + .node(node_factory_context_to_node_context(factory_context)) + end + + it "returns true for the required reference field" do + expect(node.requires?(node.properties["field_a"])).to be true + end + + it "returns false for the reference field that isn't required" do + expect(node.requires?(node.properties["field_b"])).to be false + end + end + end +end From fe52a9a3bff3b4f68726b05a5577c6300ecee795 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 16 Jan 2025 21:28:47 +0000 Subject: [PATCH 31/53] Configure type field for OpenAPI 3.1 This field is rather complicated as JSON Schema 2020-12 allows it to be an array or a string and other parts of OpenAPI don't allow different types. Another difference is that JSON Schema specification doesn't have the rule (or I didn't find it) where you require items to be specified if type is array. There are different types of items (such as prefixItems and additionalItems) in JSON Schema so I guess the items rule is complex. --- lib/openapi3_parser/node/schema.rb | 5 -- lib/openapi3_parser/node/schema/v3_0.rb | 4 + lib/openapi3_parser/node/schema/v3_1.rb | 4 + .../node_factory/schema/common.rb | 9 +-- .../node_factory/schema/v3_0.rb | 18 ++++- .../node_factory/schema/v3_1.rb | 38 ++++++++++ .../node_factory/schema/v3_0_spec.rb | 26 +++++++ .../node_factory/schema/v3_1_spec.rb | 76 +++++++++++++++++++ spec/support/examples/v3.1/changes.yaml | 2 +- spec/support/schema_common.rb | 26 ------- 10 files changed, 167 insertions(+), 41 deletions(-) diff --git a/lib/openapi3_parser/node/schema.rb b/lib/openapi3_parser/node/schema.rb index 9732825d..50a2ad61 100644 --- a/lib/openapi3_parser/node/schema.rb +++ b/lib/openapi3_parser/node/schema.rb @@ -137,11 +137,6 @@ def enum self["enum"] end - # @return [String, nil] - def type - self["type"] - end - # @return [Node::Array, nil] def all_of self["allOf"] diff --git a/lib/openapi3_parser/node/schema/v3_0.rb b/lib/openapi3_parser/node/schema/v3_0.rb index 20795005..60017844 100644 --- a/lib/openapi3_parser/node/schema/v3_0.rb +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -7,6 +7,10 @@ module Node class Schema < Node::Object # @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject class V3_0 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase + # @return [String, nil] + def type + self["type"] + end end end end diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index e2310c2a..85aad44f 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -15,6 +15,10 @@ class Schema < Node::Object # these complexities and focuses on the core schema as defined in: # https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 class V3_1 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase + # @return [String, Node::Array, nil] + def type + self["type"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/common.rb b/lib/openapi3_parser/node_factory/schema/common.rb index 7d062207..21dd2100 100644 --- a/lib/openapi3_parser/node_factory/schema/common.rb +++ b/lib/openapi3_parser/node_factory/schema/common.rb @@ -52,20 +52,13 @@ def self.included(base) base.field "example" base.field "deprecated", input_type: :boolean, default: false - base.validate :items_for_array, :read_only_or_write_only + base.validate :read_only_or_write_only end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/MethodLength private - def items_for_array(validatable) - return unless validatable.input["type"] == "array" - return unless validatable.factory.resolved_input["items"].nil? - - validatable.add_error("items must be defined for a type of array") - end - def read_only_or_write_only(validatable) input = validatable.input return if [input["readOnly"], input["writeOnly"]].uniq != [true] diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb index 243d2248..228995a5 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_0.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -9,12 +9,28 @@ class V3_0 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas include Schema::Common allow_extensions - # OpenAPI 3.0 requires a type of String, whereas 3.1 up are String or Array + # OpenAPI 3.0 requires a type of String, whereas >= 3.1 is String or Array field "type", input_type: String + validate :items_for_array + def build_node(data, node_context) Node::Schema::V3_0.new(data, node_context) end + + private + + # Only the OpenAPI 3.0 spec references the requirement for this + # validation [1]. There doesn't seem to be equivalent in JSON Schema + # 2020-12 + # + # [1]: https://spec.openapis.org/oas/v3.0.4.html#json-schema-keywords) + def items_for_array(validatable) + return unless validatable.input["type"] == "array" + return unless validatable.factory.resolved_input["items"].nil? + + validatable.add_error("items must be defined for a type of array") + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index b8bd4a55..68a422f2 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -7,14 +7,17 @@ module Openapi3Parser module NodeFactory module Schema class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase + using ArraySentence include Referenceable include Schema::Common + JSON_SCHEMA_ALLOWED_TYPES = %w[null boolean object array number string integer].freeze # 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 "type", factory: :type_factory, validate: :validate_type def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) @@ -25,6 +28,41 @@ def build_node(data, node_context) def ref_factory(context) NodeFactory::Fields::Reference.new(context, self.class) end + + def type_factory(context) + # Short circuit that we don't actually want to create a factory if we + # have string or nil input, and instead just want the data + return context.input if context.input.is_a?(String) || context.input.nil? + + NodeFactory::Array.new(context, + default: nil, + value_input_type: String) + end + + def validate_type(validatable) + return unless validatable.input + + input = validatable.input + allowed_types = JSON_SCHEMA_ALLOWED_TYPES + + case input + when String + unless allowed_types.include?(input) + validatable.add_error("type (#{input}) must be one of #{allowed_types.sentence_join}") + end + when ::Array + validatable.add_error("Duplicate entries in type array") if input.uniq.count != input.count + + if (difference = input.difference(allowed_types)).any? + validatable.add_error( + "type contains unexpected items (#{difference.sentence_join}) " \ + "outside of #{allowed_types.sentence_join}" + ) + end + else + validatable.add_error("type must be a string or an array") + end + end end end end diff --git a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb index f028fa50..ee1b92f2 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb @@ -46,4 +46,30 @@ end it_behaves_like "schema factory" + + describe "validating items" do + it "is valid when type is 'array' and items are provided" do + instance = described_class.new( + create_node_factory_context({ "type" => "array", "items" => { "type" => "string" } }) + ) + expect(instance).to be_valid + end + + it "is valid when type isn't 'array' and items aren't provided" do + instance = described_class.new( + create_node_factory_context({ "type" => "string" }) + ) + expect(instance).to be_valid + end + + it "is invalid when type is 'array' and items aren't provided" do + instance = described_class.new( + create_node_factory_context({ "type" => "array" }) + ) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/") + .with_message("items must be defined for a type of array") + 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 83d31182..dcd0ab49 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 @@ -49,4 +49,80 @@ end it_behaves_like "schema factory" + + 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| + instance = described_class.new( + create_node_factory_context({ "type" => type }) + ) + + expect(instance).to be_valid + end + end + + it "is valid for an array of unique string items" do + instance = described_class.new( + create_node_factory_context({ + "type" => described_class::JSON_SCHEMA_ALLOWED_TYPES + }) + ) + + expect(instance).to be_valid + end + + it "defaults to a value of nil" do + instance = described_class.new(create_node_factory_context({})) + + expect(instance.data["type"]).to be_nil + expect(instance).to be_valid + end + + it "is invalid for an input type other than string or array" do + instance = described_class.new( + create_node_factory_context({ "type" => { "object" => "hi" } }) + ) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/type") + .with_message("type must be a string or an array") + end + + it "is invalid for a string outside the 7 allowed types" do + instance = described_class.new( + create_node_factory_context({ "type" => "oogabooga" }) + ) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/type") + .with_message("type (oogabooga) must be one of null, boolean, object, array, number, string and integer") + end + + it "is invalid for an array with inputs other than strings" do + instance = described_class.new( + create_node_factory_context({ "type" => [12, 0.5] }) + ) + + message = "type contains unexpected items (12 and 0.5) outside of " \ + "null, boolean, object, array, number, string and integer" + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/type") + .with_message(message) + end + + it "is invalid for an array with repeated items" do + allowed_type = described_class::JSON_SCHEMA_ALLOWED_TYPES.first + factory_context = create_node_factory_context({ "type" => [allowed_type, allowed_type] }) + + instance = described_class.new(factory_context) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/type") + .with_message("Duplicate entries in type array") + end + end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index 2a31b879..097258b7 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -57,7 +57,7 @@ components: MultipleTypes: type: - string - - null + - "null" # Number: # type: integer # multipleOf: 5 diff --git a/spec/support/schema_common.rb b/spec/support/schema_common.rb index fd457ecf..90fafd9d 100644 --- a/spec/support/schema_common.rb +++ b/spec/support/schema_common.rb @@ -53,32 +53,6 @@ end end - describe "validating items" do - it "is valid when type is 'array' and items are provided" do - instance = described_class.new( - create_node_factory_context({ "type" => "array", "items" => { "type" => "string" } }) - ) - expect(instance).to be_valid - end - - it "is valid when type isn't 'array' and items aren't provided" do - instance = described_class.new( - create_node_factory_context({ "type" => "string" }) - ) - expect(instance).to be_valid - end - - it "is invalid when type is 'array' and items aren't provided" do - instance = described_class.new( - create_node_factory_context({ "type" => "array" }) - ) - expect(instance).not_to be_valid - expect(instance) - .to have_validation_error("#/") - .with_message("items must be defined for a type of array") - end - end - describe "default field" do it "supports a default field of false" do node_factory_context = create_node_factory_context({ "default" => false }) From 882ab1a520150ae7f8634f7ecef65f49502dc8e7 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 16 Jan 2025 21:37:23 +0000 Subject: [PATCH 32/53] Add const field to OpenAPI 3.1 schema As per: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-6.1.3 this can be any value. --- lib/openapi3_parser/node/schema/v3_1.rb | 5 +++++ lib/openapi3_parser/node_factory/schema/v3_1.rb | 1 + 2 files changed, 6 insertions(+) diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 85aad44f..237f159e 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -19,6 +19,11 @@ class V3_1 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase def type self["type"] end + + # @return anything + def const + self["const"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index 68a422f2..b45da3f5 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -18,6 +18,7 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "$ref", input_type: String, factory: :ref_factory field "type", factory: :type_factory, validate: :validate_type + field "const" def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) From 43d197b2b54daaad4056e0a4e54922d8302257cb Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 16 Jan 2025 22:09:31 +0000 Subject: [PATCH 33/53] Add a few basic JSON schema fields --- lib/openapi3_parser/node/schema/v3_1.rb | 17 ++++++++++++++- .../node_factory/schema/v3_1.rb | 4 ++++ .../node_factory/schema/v3_1_spec.rb | 21 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 237f159e..88e92c87 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -20,10 +20,25 @@ def type self["type"] end - # @return anything + # @return [Any] def const self["const"] end + + # @return [Integer, nil] + def max_contains + self["maxContains"] + end + + # @return [Integer] + def min_contains + self["minContains"] + end + + # @return [Node::Array] + def examples + self["examples"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index b45da3f5..bb0bacd5 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -19,6 +19,10 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "$ref", input_type: String, factory: :ref_factory field "type", factory: :type_factory, validate: :validate_type field "const" + field "maxContains", input_type: Integer + field "minContains", input_type: Integer, default: 1 + # dependentRequired - map with basic validation rules + field "examples", factory: NodeFactory::Array def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) 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 dcd0ab49..455b3be3 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 @@ -125,4 +125,25 @@ .with_message("Duplicate entries in type array") end end + + describe "examples field" do + it "is valid with an array with any type values" do + instance = described_class.new( + create_node_factory_context({ "examples" => [%w[a b], "test", nil] }) + ) + + expect(instance).to be_valid + end + + it "is invalid for a type other than array" do + instance = described_class.new( + create_node_factory_context({ "examples" => "string" }) + ) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/examples") + .with_message("Invalid type. Expected Array") + end + end end From e8163b256c16cc21f5695342af75a551e9a0cac6 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 16 Jan 2025 22:10:01 +0000 Subject: [PATCH 34/53] Update fields we're tracking for Schema in 3.1 --- json-schema-for-3.1.md | 81 +++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index b7fa914d..cd2df632 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -28,7 +28,7 @@ Dealing with the new JSON Schema approach for OpenAPI 3.1. There is some meta fields: -$ref +$ref - in 3.0 $dynamicRef $defs $schema @@ -39,52 +39,52 @@ $dynamicAnchor Then a ton of fields: -type: string -enum: array -const: any type -multipleOf: number -maximum: number -exclusiveMaximum: number -minimum: number -exclusiveMinimum: number -maxLength: integer >= 0 -minLength: integer >= 0 -pattern: string -maxItems: integer >= 0 -minItems: integer >= 0 -uniqueItems: boolean -maxContains: integer >= 0 -minContains: integer >= 0 -maxProperties: integer >= 0 -minProperties: integer >= 0 +type: string - in 3.0 +enum: array - in 3.0 +const: any type - done +multipleOf: number - in 3.0 +maximum: number - in 3.0 +exclusiveMaximum: number - in 3.0 +minimum: number - in 3.0 +exclusiveMinimum: number - in 3.0 +maxLength: integer >= 0 - in 3.0 +minLength: integer >= 0 - in 3.0 +pattern: string - in 3.0 +maxItems: integer >= 0 - in 3.0 +minItems: integer >= 0 - in 3.0 +uniqueItems: boolean - in 3.0 +maxContains: integer >= 0 - done +minContains: integer >= 0 - done +maxProperties: integer >= 0 - in 3.0 +minProperties: integer >= 0 - in 3.0 required: array, strings, unique -dependentRequired: something complex +dependentRequired: something complex contentEncoding: string contentMediaType: string / media type contentSchema: schema -title: string -description: string -default: any -deprecated: boolean (default false) -readOnly: boolean (default false) -writeOnly: boolean (default false) -examples: array - - -allOf - non empty array of schemas -anyOf - non empty array of schemas -oneOf - non empty array of schemas -not - schema +title: string - in 3.0 +description: string - in 3.0 +default: any - in 3.0 +deprecated: boolean (default false) - in 3.0 +readOnly: boolean (default false) - in 3.0 +writeOnly: boolean (default false) - in 3.0 +examples: array - done +format: any - in 3.0 + +allOf - non empty array of schemas - in 3.0 +anyOf - non empty array of schemas - in 3.0 +oneOf - non empty array of schemas - in 3.0 +not - schema - in 3.0 if - single schema then - single schema else - single schema prefixItems: schema -items: schema +items: schema - in 3.0 contains: schema -properties: object, each value json schema +properties: object, each value json schema - in 3.0 patternProperties: object each value JSON schema additionalProperties: single json schema @@ -103,4 +103,13 @@ At point of shutting down on 10th January 2025 I was wondering about how schemas I learnt that merging seems largely undefined in JSON Schema, as far as I can tell and I'm just going with a strategy of most recent field wins. I've set up a Node::Schema class for common schema methods and Node::Schema::v3_0 and v3_1Up classes for specific changes. Need to flesh out -tests and then behaviour that differs between them +tests and then behaviour that differs between them. + +Little things: +- schema integer fields generally are required to be non-negative +- quite common for arrays to be invalid if not unique (required, type) + +JSON Schema specs: + +meta: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00 +validation: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00 From d7f2f81d3724e1121dfefa4ccf7e993dd664adc8 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 17 Jan 2025 21:26:49 +0000 Subject: [PATCH 35/53] Add content* fields for v3.1 schema object I used the specs from: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-8.3 --- json-schema-for-3.1.md | 13 ++++++------ lib/openapi3_parser/node/schema/v3_1.rb | 15 +++++++++++++ .../node_factory/schema/v3_1.rb | 6 ++++++ .../node_factory/schema/v3_1_spec.rb | 21 +++++++++++++++++++ spec/support/examples/v3.1/changes.yaml | 21 +++++++++++++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index cd2df632..ec7312dd 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -47,7 +47,7 @@ maximum: number - in 3.0 exclusiveMaximum: number - in 3.0 minimum: number - in 3.0 exclusiveMinimum: number - in 3.0 -maxLength: integer >= 0 - in 3.0 +maxLength: integer >= 0 - in 3.0 (missing >= val) minLength: integer >= 0 - in 3.0 pattern: string - in 3.0 maxItems: integer >= 0 - in 3.0 @@ -57,11 +57,11 @@ maxContains: integer >= 0 - done minContains: integer >= 0 - done maxProperties: integer >= 0 - in 3.0 minProperties: integer >= 0 - in 3.0 -required: array, strings, unique -dependentRequired: something complex -contentEncoding: string -contentMediaType: string / media type -contentSchema: schema +required: array, strings, unique - in 3.0 (missing unique) +dependentRequired: something complex +contentEncoding: string - done +contentMediaType: string / media type - done +contentSchema: schema - done title: string - in 3.0 description: string - in 3.0 default: any - in 3.0 @@ -108,6 +108,7 @@ tests and then behaviour that differs between them. Little things: - schema integer fields generally are required to be non-negative - quite common for arrays to be invalid if not unique (required, type) +- probably want a quick way to get coverage of the methods on nodes JSON Schema specs: diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 88e92c87..7fbe89f4 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -39,6 +39,21 @@ def min_contains def examples self["examples"] end + + # @return [String, nil] + def content_encoding + self["contentEncoding"] + end + + # @return [String, nil] + def content_media_type + self["contentMediaType"] + end + + # @return [Schema, nil] + def content_schema + self["contentSchema"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index bb0bacd5..e421d3d1 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/validators/media_type" module Openapi3Parser module NodeFactory @@ -23,6 +24,11 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "minContains", input_type: Integer, default: 1 # dependentRequired - map with basic validation rules field "examples", factory: NodeFactory::Array + field "contentEncoding", input_type: String + field "contentMediaType", + input_type: String, + validate: Validation::InputValidator.new(Validators::MediaType) + field "contentSchema", factory: :referenceable_schema def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) 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 455b3be3..53ecf5d7 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 @@ -146,4 +146,25 @@ .with_message("Invalid type. Expected Array") end end + + describe "contentMediaType field" do + it "is valid with a media type string" do + instance = described_class.new( + create_node_factory_context({ "contentMediaType" => "image/png" }) + ) + + expect(instance).to be_valid + end + + it "is invalid with a non media type string" do + instance = described_class.new( + create_node_factory_context({ "contentMediaType" => "not a media type" }) + ) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/contentMediaType") + .with_message('"not a media type" is not a valid media type') + end + end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index 097258b7..9fb6463f 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -58,6 +58,27 @@ components: type: - string - "null" + ContentEncoding: + type: string + contentEncoding: base64 + contentMediaType: image/png + ContentSchema: + type: string + contentMediaType: application/jwt + contentSchema: + type: array + minItems: 2 + prefixItems: + - const: + type: JWT + alg: HS256 + - type: object + required: [iss, exp] + properties: + iss: + type: string + exp: + type: string # Number: # type: integer # multipleOf: 5 From 70b10afe1ce59302f689467a68daba9569c98027 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 17 Jan 2025 21:57:41 +0000 Subject: [PATCH 36/53] Add definitions for if, then and else schema fields I didn't think I'd be able to use these words as method names as they are Ruby reserved keywords. However as I understand it is ok because Ruby can work with them contextual (i.e. they're always prefixed by an object e.g self.if) Spec used: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-10.2.2.1 --- json-schema-for-3.1.md | 11 +++++---- lib/openapi3_parser/node/schema/v3_1.rb | 15 ++++++++++++ .../node_factory/schema/v3_1.rb | 3 +++ .../openapi3_parser/node/schema/v3_1_spec.rb | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index ec7312dd..c71c17c9 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -76,12 +76,13 @@ anyOf - non empty array of schemas - in 3.0 oneOf - non empty array of schemas - in 3.0 not - schema - in 3.0 -if - single schema -then - single schema -else - single schema +if - single schema - done +then - single schema - done +else - single schema - done +dependentSchemas - map of schemas - somewhat complex, is it related to dependentRequired ? -prefixItems: schema -items: schema - in 3.0 +prefixItems: array of schema +items: array of schema - in 3.0 contains: schema properties: object, each value json schema - in 3.0 diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 7fbe89f4..637f8a25 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -54,6 +54,21 @@ def content_media_type def content_schema self["contentSchema"] end + + # @return [Schema, nil] + def if + self["if"] + end + + # @return [Schema, nil] + def then + self["then"] + end + + # @return [Schema, nil] + def else + self["else"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index e421d3d1..ee823dd1 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -29,6 +29,9 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas input_type: String, validate: Validation::InputValidator.new(Validators::MediaType) field "contentSchema", factory: :referenceable_schema + field "if", factory: :referenceable_schema + field "then", factory: :referenceable_schema + field "else", factory: :referenceable_schema def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) diff --git a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb index 7c0427d6..f2bff448 100644 --- a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb +++ b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb @@ -2,4 +2,28 @@ RSpec.describe Openapi3Parser::Node::Schema::V3_1 do it_behaves_like "schema node", openapi_version: "3.1.0" + + %i[if then else].each do |method_name| + describe method_name.to_s do + it "supports a Ruby reserved word as a method name" do + factory_context = create_node_factory_context( + { method_name.to_s => { "type" => "string" } }, + document_input: { + "openapi" => "3.1.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + } + } + ) + + instance = Openapi3Parser::NodeFactory::Schema::V3_1 + .new(factory_context) + .node(node_factory_context_to_node_context(factory_context)) + + expect(instance.public_send(method_name)) + .to be_an_instance_of(described_class) + end + end + end end From a35b54bfa21de5aa30914b18b20c60271ebb4ac8 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Mon, 27 Jan 2025 20:49:42 +0000 Subject: [PATCH 37/53] Add prefixItems field to Schema Based on definition from [1] where it's a list of schemas that defaults to an empty array [1]: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-10.3.1.1 --- json-schema-for-3.1.md | 2 +- lib/openapi3_parser/node/schema/v3_1.rb | 5 +++ .../node_factory/schema/v3_1.rb | 8 +++++ .../node_factory/schema/v3_1_spec.rb | 32 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index c71c17c9..5af42de8 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -81,7 +81,7 @@ then - single schema - done else - single schema - done dependentSchemas - map of schemas - somewhat complex, is it related to dependentRequired ? -prefixItems: array of schema +prefixItems: array of schema - done items: array of schema - in 3.0 contains: schema diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 637f8a25..37246bf7 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -69,6 +69,11 @@ def then def else self["else"] end + + # @return [Node::Array] + def prefix_items + self["prefixItems"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index ee823dd1..3f0a64b8 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -32,6 +32,7 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "if", factory: :referenceable_schema field "then", factory: :referenceable_schema field "else", factory: :referenceable_schema + field "prefixItems", factory: :prefix_items_factory def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) @@ -77,6 +78,13 @@ def validate_type(validatable) validatable.add_error("type must be a string or an array") end end + + def prefix_items_factory(context) + NodeFactory::Array.new( + context, + value_factory: NodeFactory::Schema.factory(context) + ) + end 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 53ecf5d7..9e9a5fe7 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 @@ -167,4 +167,36 @@ .with_message('"not a media type" is not a valid media type') end end + + describe "prefixItems field" do + it "is valid with an array with schema values" do + instance = described_class.new( + create_node_factory_context({ "prefixItems" => [{ "type" => "string" }] }) + ) + + expect(instance).to be_valid + end + + it "is invalid for a type other than array" do + instance = described_class.new( + create_node_factory_context({ "prefixItems" => "string" }) + ) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/prefixItems") + .with_message("Invalid type. Expected Array") + end + + it "is invalid for values other than objects" do + instance = described_class.new( + create_node_factory_context({ "prefixItems" => %w[string] }) + ) + + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/prefixItems/0") + .with_message("Invalid type. Expected Object") + end + end end From af96354d0df401377eaad4661fedf6e5d84242c9 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Mon, 27 Jan 2025 20:57:50 +0000 Subject: [PATCH 38/53] Contains field for schema As per: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-10.3.1.1 --- json-schema-for-3.1.md | 2 +- lib/openapi3_parser/node/schema/v3_1.rb | 5 +++ .../node_factory/schema/v3_1.rb | 1 + spec/support/examples/v3.1/changes.yaml | 42 +++++++++---------- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index 5af42de8..3e8dff86 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -83,7 +83,7 @@ dependentSchemas - map of schemas - somewhat complex, is it related to dependent prefixItems: array of schema - done items: array of schema - in 3.0 -contains: schema +contains: schema - done properties: object, each value json schema - in 3.0 patternProperties: object each value JSON schema diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 37246bf7..adc57594 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -74,6 +74,11 @@ def else def prefix_items self["prefixItems"] end + + # @return [Schema, nil] + def contains + self["contains"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index 3f0a64b8..ef915f9f 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -33,6 +33,7 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "then", factory: :referenceable_schema field "else", factory: :referenceable_schema field "prefixItems", factory: :prefix_items_factory + field "contains", factory: :referenceable_schema def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index 9fb6463f..77b83dc5 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -86,27 +86,27 @@ components: # exclusiveMaximum: 111 # minimum: 10 # exclusiveMinimum: 9 - # String: - # type: string - # maxLength: 10 - # minLength: 5 - # pattern: "[a-z]*" - # Array: - # type: array - # maxItems: 10 - # minItems: 1 - # uniqueItems: true - # contains: - # const: "test" - # minContains: 1 - # maxContains: 1 - # prefixItems: - # - const: "item" - # type: string - # items: - # type: string - # unevaluatedItems: - # type: string + String: + type: string + maxLength: 10 + minLength: 5 + pattern: "[a-z]*" + Array: + type: array + maxItems: 10 + minItems: 1 + uniqueItems: true + contains: + const: "test" + minContains: 1 + maxContains: 1 + prefixItems: + - const: "item" + type: string + items: + type: string + unevaluatedItems: + type: string # Add object # Add content types # Add $ref usage (plain, merged, defs) From 123b76b21c51889de349921ac3ebd8ecb89c7e1d Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Mon, 27 Jan 2025 21:14:33 +0000 Subject: [PATCH 39/53] Correct handling of minimum and maximum of schema This fixes an error in OpenAPI 3.0 schema handling where minimum and maximum were flagged as integers when actually they are numeric. It then corrects the configuration of exclusiveMaximum and exclusiveMinimum for OpenAPI 3.1 where JSON Schema 2021 [1] defines these as numeric values compared to the boolean values they held in OpenAPI 3.0 (via JSON Schema 2017 [2]). [1]: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-6.2.3 [2]: https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.3 --- json-schema-for-3.1.md | 4 ++-- lib/openapi3_parser/node/schema.rb | 14 ++------------ lib/openapi3_parser/node/schema/v3_0.rb | 10 ++++++++++ lib/openapi3_parser/node/schema/v3_1.rb | 10 ++++++++++ lib/openapi3_parser/node_factory/schema/common.rb | 6 ++---- lib/openapi3_parser/node_factory/schema/v3_0.rb | 5 +++++ lib/openapi3_parser/node_factory/schema/v3_1.rb | 2 ++ spec/support/examples/v3.1/changes.yaml | 14 +++++++------- 8 files changed, 40 insertions(+), 25 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index 3e8dff86..6271bb71 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -44,9 +44,9 @@ enum: array - in 3.0 const: any type - done multipleOf: number - in 3.0 maximum: number - in 3.0 -exclusiveMaximum: number - in 3.0 +exclusiveMaximum: number - done minimum: number - in 3.0 -exclusiveMinimum: number - in 3.0 +exclusiveMinimum: number - done maxLength: integer >= 0 - in 3.0 (missing >= val) minLength: integer >= 0 - in 3.0 pattern: string - in 3.0 diff --git a/lib/openapi3_parser/node/schema.rb b/lib/openapi3_parser/node/schema.rb index 50a2ad61..44aaf67f 100644 --- a/lib/openapi3_parser/node/schema.rb +++ b/lib/openapi3_parser/node/schema.rb @@ -50,26 +50,16 @@ def multiple_of self["multipleOf"] end - # @return [Integer, nil] + # @return [Numeric, nil] def maximum self["maximum"] end - # @return [Boolean] - def exclusive_maximum? - self["exclusiveMaximum"] - end - - # @return [Integer, nil] + # @return [Numeric, nil] def minimum self["minimum"] end - # @return [Boolean] - def exclusive_minimum? - self["exclusiveMinimum"] - end - # @return [Integer, nil] def max_length self["maxLength"] diff --git a/lib/openapi3_parser/node/schema/v3_0.rb b/lib/openapi3_parser/node/schema/v3_0.rb index 60017844..bf0da0db 100644 --- a/lib/openapi3_parser/node/schema/v3_0.rb +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -11,6 +11,16 @@ class V3_0 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase def type self["type"] end + + # @return [Boolean] + def exclusive_maximum? + self["exclusiveMaximum"] + end + + # @return [Boolean] + def exclusive_minimum? + self["exclusiveMinimum"] + end end end end diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index adc57594..d3874d4b 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -25,6 +25,16 @@ def const self["const"] end + # @return [Numeric] + def exclusive_maximum + self["exclusiveMaximum"] + end + + # @return [Numeric] + def exclusive_minimum + self["exclusiveMinimum"] + end + # @return [Integer, nil] def max_contains self["maxContains"] diff --git a/lib/openapi3_parser/node_factory/schema/common.rb b/lib/openapi3_parser/node_factory/schema/common.rb index 21dd2100..adc6c2c7 100644 --- a/lib/openapi3_parser/node_factory/schema/common.rb +++ b/lib/openapi3_parser/node_factory/schema/common.rb @@ -14,10 +14,8 @@ def self.included(base) base.field "title", input_type: String base.field "multipleOf", input_type: Numeric - base.field "maximum", input_type: Integer - base.field "exclusiveMaximum", input_type: :boolean, default: false - base.field "minimum", input_type: Integer - base.field "exclusiveMinimum", input_type: :boolean, default: false + base.field "maximum", input_type: Numeric + base.field "minimum", input_type: Numeric base.field "maxLength", input_type: Integer base.field "minLength", input_type: Integer, default: 0 base.field "pattern", input_type: String diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb index 228995a5..2efa68ce 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_0.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -12,6 +12,11 @@ class V3_0 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas # OpenAPI 3.0 requires a type of String, whereas >= 3.1 is String or Array field "type", input_type: String + # JSON Schema 2016 has these exclusive fields as booleans whereas + # in JSON Schema 2021 (OpenAPI 3.1) these are numbers + field "exclusiveMaximum", input_type: :boolean, default: false + field "exclusiveMinimum", input_type: :boolean, default: false + validate :items_for_array def build_node(data, node_context) diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index ef915f9f..e4d9d798 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -20,6 +20,8 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "$ref", input_type: String, factory: :ref_factory field "type", factory: :type_factory, validate: :validate_type field "const" + field "exclusiveMaximum", input_type: Numeric + field "exclusiveMinimum", input_type: Numeric field "maxContains", input_type: Integer field "minContains", input_type: Integer, default: 1 # dependentRequired - map with basic validation rules diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index 77b83dc5..223be4b1 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -79,13 +79,13 @@ components: type: string exp: type: string - # Number: - # type: integer - # multipleOf: 5 - # maximum: 110 - # exclusiveMaximum: 111 - # minimum: 10 - # exclusiveMinimum: 9 + Number: + type: integer + multipleOf: 5 + maximum: 110 + exclusiveMaximum: 111 + minimum: 10 + exclusiveMinimum: 9 String: type: string maxLength: 10 From ed007b9d44b411c18d4b0faf5f55a15316009b26 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Mon, 27 Jan 2025 21:35:48 +0000 Subject: [PATCH 40/53] Add patternProperties to Schema Based on JSON schema 2021 [1]. Where the value is expected to be a map where the key is a regex (to match a string property name) and value is a Schema object. Like the implementation of pattern in this codebase this doesn't have validation that pattern is a regex, which could be added. [1]: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-10.3.1.1 --- json-schema-for-3.1.md | 3 ++- lib/openapi3_parser/node/schema/v3_1.rb | 5 +++++ lib/openapi3_parser/node_factory/schema/v3_1.rb | 8 ++++++++ spec/support/examples/v3.1/changes.yaml | 6 +++++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index 6271bb71..19294fa5 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -86,7 +86,7 @@ items: array of schema - in 3.0 contains: schema - done properties: object, each value json schema - in 3.0 -patternProperties: object each value JSON schema +patternProperties: object each value JSON schema key regex - done additionalProperties: single json schema unevaluatedItems - single schema @@ -110,6 +110,7 @@ Little things: - schema integer fields generally are required to be non-negative - quite common for arrays to be invalid if not unique (required, type) - probably want a quick way to get coverage of the methods on nodes +- could validate that pattern and patternProperties contain regexs JSON Schema specs: diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index d3874d4b..b8886308 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -89,6 +89,11 @@ def prefix_items def contains self["contains"] end + + # @return [Node::Map] + def pattern_properties + self["patternProperties"] + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index e4d9d798..dc16a0cd 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -36,6 +36,7 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "else", factory: :referenceable_schema field "prefixItems", factory: :prefix_items_factory field "contains", factory: :referenceable_schema + field "patternProperties", factory: :pattern_properties_factory def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) @@ -88,6 +89,13 @@ def prefix_items_factory(context) value_factory: NodeFactory::Schema.factory(context) ) end + + def pattern_properties_factory(context) + NodeFactory::Map.new( + context, + value_factory: NodeFactory::Schema.factory(context) + ) + end end end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index 223be4b1..456b8064 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -107,7 +107,11 @@ components: type: string unevaluatedItems: type: string - # Add object + Object: + type: object + patternProperties: + /^test/: + type: string # Add content types # Add $ref usage (plain, merged, defs) # Add compound things: anyOf, oneOf, not, if, then, else From 87332787755ea326525fdec4abd5ddd2a4f10b93 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Mon, 27 Jan 2025 21:52:49 +0000 Subject: [PATCH 41/53] Add some notes about boolean schemas Something to think about adding soon --- json-schema-for-3.1.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index 19294fa5..0d05c64f 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -87,10 +87,10 @@ contains: schema - done properties: object, each value json schema - in 3.0 patternProperties: object each value JSON schema key regex - done -additionalProperties: single json schema +additionalProperties: single json schema (works with the boolean schema value that we don't support) -unevaluatedItems - single schema -unevaluatedProperties: single schema +unevaluatedItems - single schema - somewhat complex because of booleanSchemas +unevaluatedProperties: single schema - as above ## Returning to this in 2025 @@ -112,6 +112,9 @@ Little things: - probably want a quick way to get coverage of the methods on nodes - could validate that pattern and patternProperties contain regexs +Bigger things: +- OpenAPI 3.1 supports boolean schemas that are just a true or false value, this code doesn't. + JSON Schema specs: meta: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00 From 3ed1a7da0487e10bc04b5dea44fde6f4476655ed Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 29 Jan 2025 10:16:53 +0000 Subject: [PATCH 42/53] Add dependentRequired field for 3.1 schema Based on https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00 --- json-schema-for-3.1.md | 2 +- lib/openapi3_parser/node/schema/v3_1.rb | 5 +++++ lib/openapi3_parser/node_factory/schema/v3_1.rb | 13 ++++++++++++- spec/integration/open_v3.1_examples_spec.rb | 13 +++++++++++-- spec/support/examples/v3.1/changes.yaml | 15 +++++++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index 0d05c64f..e1e71e70 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -58,7 +58,7 @@ minContains: integer >= 0 - done maxProperties: integer >= 0 - in 3.0 minProperties: integer >= 0 - in 3.0 required: array, strings, unique - in 3.0 (missing unique) -dependentRequired: something complex +dependentRequired: something complex done contentEncoding: string - done contentMediaType: string / media type - done contentSchema: schema - done diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index b8886308..c285669a 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -50,6 +50,11 @@ def examples self["examples"] end + # @return [Node::Map>] + def dependent_required + self["dependentRequired"] + end + # @return [String, nil] def content_encoding self["contentEncoding"] diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index dc16a0cd..1412630f 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -24,8 +24,8 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "exclusiveMinimum", input_type: Numeric field "maxContains", input_type: Integer field "minContains", input_type: Integer, default: 1 - # dependentRequired - map with basic validation rules field "examples", factory: NodeFactory::Array + field "dependentRequired", factory: :dependent_required_factory field "contentEncoding", input_type: String field "contentMediaType", input_type: String, @@ -83,6 +83,17 @@ def validate_type(validatable) end end + def dependent_required_factory(context) + value_factory = lambda do |value_context| + NodeFactory::Array.new(value_context, value_input_type: String) + end + + NodeFactory::Map.new( + context, + value_factory: + ) + end + def prefix_items_factory(context) NodeFactory::Array.new( context, diff --git a/spec/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb index 649c47d1..38cb6cdb 100644 --- a/spec/integration/open_v3.1_examples_spec.rb +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -45,10 +45,19 @@ end it "can access a referenced schema" do - expect(document.components.schemas["DoubleReferencedSchema"]["required"]) + expect(document.components.schemas["DoubleReferencedSchema"].required) .to match_array(%w[id name]) - expect(document.components.schemas["DoubleReferencedSchema"]["description"]) + expect(document.components.schemas["DoubleReferencedSchema"].description) .to eq("My double referenced schema") end + + it "can parse and navigate a dependentRequired field" do + schema = document.components.schemas["DependentRequired"] + + expect(schema.dependent_required).to be_a(Openapi3Parser::Node::Map) + expect(schema.dependent_required.keys).to match_array(%w[credit_card]) + expect(schema.dependent_required["credit_card"]).to be_a(Openapi3Parser::Node::Array) + expect(schema.dependent_required["credit_card"]).to match_array(%w[billing_address]) + end end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index 456b8064..e6490e03 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -79,6 +79,20 @@ components: type: string exp: type: string + DependentRequired: + type: object + properties: + name: + type: string + credit_card: + type: number + billing_address: + type: string + required: + - name + dependentRequired: + credit_card: + - billing_address Number: type: integer multipleOf: 5 @@ -112,6 +126,7 @@ components: patternProperties: /^test/: type: string + # Boolean: true # Add content types # Add $ref usage (plain, merged, defs) # Add compound things: anyOf, oneOf, not, if, then, else From 2f11358ec072d372fb269a8b6e388f59cd3595e1 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 29 Jan 2025 12:05:46 +0000 Subject: [PATCH 43/53] Add DependentSchemas schema field Based on: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-10.2.2.4 --- json-schema-for-3.1.md | 2 +- lib/openapi3_parser/node/schema/v3_1.rb | 5 +++++ .../node_factory/schema/common.rb | 4 ++-- lib/openapi3_parser/node_factory/schema/v3_1.rb | 10 ++-------- spec/support/examples/v3.1/changes.yaml | 16 ++++++++++++++++ 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index e1e71e70..3da12fec 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -79,7 +79,7 @@ not - schema - in 3.0 if - single schema - done then - single schema - done else - single schema - done -dependentSchemas - map of schemas - somewhat complex, is it related to dependentRequired ? +dependentSchemas - map of schemas - done prefixItems: array of schema - done items: array of schema - in 3.0 diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index c285669a..4b60e994 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -85,6 +85,11 @@ def else self["else"] end + # @return [Node::Map] + def dependent_schemas + self["dependentSchemas"] + end + # @return [Node::Array] def prefix_items self["prefixItems"] diff --git a/lib/openapi3_parser/node_factory/schema/common.rb b/lib/openapi3_parser/node_factory/schema/common.rb index adc6c2c7..1018cf14 100644 --- a/lib/openapi3_parser/node_factory/schema/common.rb +++ b/lib/openapi3_parser/node_factory/schema/common.rb @@ -32,7 +32,7 @@ def self.included(base) base.field "anyOf", factory: :referenceable_schema_array base.field "not", factory: :referenceable_schema base.field "items", factory: :referenceable_schema - base.field "properties", factory: :properties_factory + base.field "properties", factory: :schema_map_factory base.field "additionalProperties", validate: :additional_properties_input_type, factory: :additional_properties_factory, @@ -88,7 +88,7 @@ def external_docs_factory(context) NodeFactory::ExternalDocumentation.new(context) end - def properties_factory(context) + def schema_map_factory(context) NodeFactory::Map.new( context, value_factory: NodeFactory::Schema.factory(context) diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index 1412630f..519a82e7 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -34,9 +34,10 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "if", factory: :referenceable_schema field "then", factory: :referenceable_schema field "else", factory: :referenceable_schema + field "dependentSchemas", factory: :schema_map_factory field "prefixItems", factory: :prefix_items_factory field "contains", factory: :referenceable_schema - field "patternProperties", factory: :pattern_properties_factory + field "patternProperties", factory: :schema_map_factory def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) @@ -100,13 +101,6 @@ def prefix_items_factory(context) value_factory: NodeFactory::Schema.factory(context) ) end - - def pattern_properties_factory(context) - NodeFactory::Map.new( - context, - value_factory: NodeFactory::Schema.factory(context) - ) - end end end end diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index e6490e03..ef09f32c 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -93,6 +93,22 @@ components: dependentRequired: credit_card: - billing_address + DependentSchemas: + type: object + properties: + name: + type: string + credit_card: + type: number + required: + - name + dependentSchemas: + credit_card: + properties: + billing_address: + type: string + required: + - billing_address Number: type: integer multipleOf: 5 From f75b2d61196044a3f24f19cf76ac5edd597c842e Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 29 Jan 2025 21:54:46 +0000 Subject: [PATCH 44/53] Check explicitly for ::Hash rather than respond_to?(:[]) This is to fix minor errors that occur if a class other than hashes are used in these places. In these situations we were checking if objects responded to [] before using the method with a string key. However there are a bunch of objects that respond to [] and error if you provide a string key. E.g: ``` irb(main):001:0> arr = [] => [] irb(main):002:0> arr["$ref"] (irb):2:in `[]': no implicit conversion of String into Integer (TypeError) from (irb):2:in `
' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli/console.rb:19:in `run' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli.rb:516:in `console' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor/command.rb:27:in `run' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor.rb:392:in `dispatch' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli.rb:31:in `dispatch' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor/base.rb:485:in `start' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli.rb:25:in `start' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/bundler-2.3.27/libexec/bundle:48:in `block in ' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/friendly_errors.rb:120:in `with_friendly_errors' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/bundler-2.3.27/libexec/bundle:36:in `' from /Users/kevin.dew/.rbenv/versions/3.1.5/bin/bundle:25:in `load' from /Users/kevin.dew/.rbenv/versions/3.1.5/bin/bundle:25:in `
' irb(main):005:0> int = 25 => 25 irb(main):006:0> int["$ref"] (irb):6:in `[]': no implicit conversion of String into Integer (TypeError) from (irb):6:in `
' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli/console.rb:19:in `run' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli.rb:516:in `console' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor/command.rb:27:in `run' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor.rb:392:in `dispatch' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli.rb:31:in `dispatch' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/vendor/thor/lib/thor/base.rb:485:in `start' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/cli.rb:25:in `start' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/bundler-2.3.27/libexec/bundle:48:in `block in ' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/3.1.0/bundler/friendly_errors.rb:120:in `with_friendly_errors' from /Users/kevin.dew/.rbenv/versions/3.1.5/lib/ruby/gems/3.1.0/gems/bundler-2.3.27/libexec/bundle:36:in `' from /Users/kevin.dew/.rbenv/versions/3.1.5/bin/bundle:25:in `load' from /Users/kevin.dew/.rbenv/versions/3.1.5/bin/bundle:25:in `
' ``` I think there's very low or hopefully non existent chance that we'd be dealing with a hash like object in these scenarios so it seems safe to me to do the explicit type checking. --- lib/openapi3_parser/node_factory/context.rb | 2 +- .../node_factory/object_factory/node_builder.rb | 2 +- lib/openapi3_parser/node_factory/referenceable.rb | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/openapi3_parser/node_factory/context.rb b/lib/openapi3_parser/node_factory/context.rb index 723adf26..c97e0395 100644 --- a/lib/openapi3_parser/node_factory/context.rb +++ b/lib/openapi3_parser/node_factory/context.rb @@ -36,7 +36,7 @@ def self.root(input, source) def self.next_field(parent_context, field, given_input = UNDEFINED) pc = parent_context input = if given_input == UNDEFINED - pc.input.respond_to?(:[]) ? pc.input[field] : nil + pc.input.is_a?(::Hash) ? pc.input[field] : nil else given_input end diff --git a/lib/openapi3_parser/node_factory/object_factory/node_builder.rb b/lib/openapi3_parser/node_factory/object_factory/node_builder.rb index ee1a7197..4d98ead0 100644 --- a/lib/openapi3_parser/node_factory/object_factory/node_builder.rb +++ b/lib/openapi3_parser/node_factory/object_factory/node_builder.rb @@ -75,7 +75,7 @@ def validate # most fields are validated during building, however we delete $ref # fields so need to validate them separately ([initial_factory] + referenced_factories).each do |factory| - next unless factory.data.respond_to?(:[]) + next unless factory.data.is_a?(::Hash) next unless factory.data["$ref"].is_a?(NodeFactory::Fields::Reference) NodeFactory::Field::Validator.call(factory.data["$ref"], raise_on_invalid: true) diff --git a/lib/openapi3_parser/node_factory/referenceable.rb b/lib/openapi3_parser/node_factory/referenceable.rb index 7076b11b..52408f0e 100644 --- a/lib/openapi3_parser/node_factory/referenceable.rb +++ b/lib/openapi3_parser/node_factory/referenceable.rb @@ -4,13 +4,13 @@ module Openapi3Parser module NodeFactory module Referenceable def in_recursive_loop? - return false unless data.respond_to?(:[]) + return false unless data.is_a?(::Hash) data["$ref"]&.self_referencing? end def referenced_factory - return unless data.respond_to?(:[]) + return unless data.is_a?(::Hash) data["$ref"]&.referenced_factory end From 6d16da6ebec5407f2bd21b1b2a81ca28b1658766 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 29 Jan 2025 21:59:25 +0000 Subject: [PATCH 45/53] Support JsonSchemas that are a boolean value JsonSchema 2020-12 has a rather complex quirk that providing a boolean is treated as a valid schema. Where a value of `true` means the schema will pass anything and a value of `false` means the schema will pass nothing [1]. This is quite a pain to model as there is an assumption in this libraries code that a node can only be one type and that all references resolve to a map type. This code configures the factory to produce a v3_1 schema node when given these, in a subsequent commit I will configure the node class to make use of these. [1]: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-4.3.2 --- .../object_factory/resolved_input_builder.rb | 2 +- .../node_factory/schema/v3_1.rb | 20 ++++++ .../resolved_input_builder_spec.rb | 19 ++++++ .../node_factory/schema/v3_1_spec.rb | 63 +++++++++++++++++++ spec/support/examples/v3.1/changes.yaml | 4 +- 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb b/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb index 09e0c1e1..b89d7a85 100644 --- a/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb +++ b/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder.rb @@ -34,7 +34,7 @@ def referenced_factories def merge_factory_input(factories) input = factories.reverse.inject({}) do |memo, factory| - next memo unless factory.data.respond_to?(:[]) + return factory.data unless factory.data.is_a?(::Hash) remove_reference = factory.data["$ref"]&.is_a?(NodeFactory::Fields::Reference) diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index 519a82e7..e143d3b8 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -39,12 +39,32 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "contains", factory: :referenceable_schema field "patternProperties", factory: :schema_map_factory + def boolean_input? + [true, false].include?(resolved_input) + end + + def errors + @errors ||= boolean_input? ? [] : super + end + + def node(node_context) + return super unless boolean_input? + + Node::Schema::V3_1.new({ "boolean" => resolved_input }, node_context) + end + def build_node(data, node_context) Node::Schema::V3_1.new(data, node_context) end private + def build_data(raw_input) + return raw_input if [true, false].include?(raw_input) + + super + end + def ref_factory(context) NodeFactory::Fields::Reference.new(context, self.class) end diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb index 88aeada9..50c250d8 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb @@ -27,6 +27,12 @@ field "first_name" field "last_name" + def build_data(raw_input) + return raw_input unless raw_input.is_a?(Hash) + + super + end + def ref_factory(context) Openapi3Parser::NodeFactory::Fields::Reference.new(context, self.class) end @@ -86,6 +92,19 @@ def ref_factory(context) expect(described_class.call(factory)).to be_nil end + it "returns the value if any of the referenced data isn't a hash" do + factory_context = create_node_factory_context( + { "$ref" => "#/reference_a" }, + document_input: { + "reference_a" => 25 + } + ) + + factory = factory_class.new(factory_context) + + expect(described_class.call(factory)).to eq(25) + end + it "returns a RecursiveResolvedInput object for node data that is in a recursive loop" do factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Reference" }, 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 9e9a5fe7..146ee836 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 @@ -50,6 +50,69 @@ it_behaves_like "schema factory" + describe "boolean input" do + it "is valid for a boolean input" do + instance = described_class.new(create_node_factory_context(false)) + + expect(instance).to be_valid + expect(instance.boolean_input?).to be(true) + end + + it "can build a Schema::V3_1 node with a boolean input" do + node_factory_context = create_node_factory_context(true) + instance = described_class.new(node_factory_context) + node_context = node_factory_context_to_node_context(node_factory_context) + node = instance.node(node_context) + + expect(node).to be_an_instance_of(Openapi3Parser::Node::Schema::V3_1) + end + + it "sets the data attribute to a boolean when that is input" do + node_factory_context = create_node_factory_context(true) + instance = described_class.new(node_factory_context) + + expect(instance.data).to be(true) + end + + it "sets the data attribute to nil when given a type other than boolean or object" do + node_factory_context = create_node_factory_context(25) + instance = described_class.new(node_factory_context) + + expect(instance.data).to be_nil + end + + context "when a referenced schema is a boolean" do + let(:document_input) do + { + "components" => { + "schemas" => { + "Bool" => true + } + } + } + end + + it "is valid" do + input = { "$ref" => "#/components/schemas/Bool" } + instance = described_class.new(create_node_factory_context(input, document_input:)) + + expect(instance).to be_valid + expect(instance.boolean_input?).to be(true) + end + + it "doesn't merge any fields" do + input = { + "description" => "A description that will be ignored", + "$ref" => "#/components/schemas/Bool" + } + + instance = described_class.new(create_node_factory_context(input, document_input:)) + + expect(instance.resolved_input).to be(true) + 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/changes.yaml b/spec/support/examples/v3.1/changes.yaml index ef09f32c..ba8bc159 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -142,7 +142,9 @@ components: patternProperties: /^test/: type: string - # Boolean: true + Boolean: true + ReferencedBoolean: + $ref: "#/components/schemas/Boolean" # Add content types # Add $ref usage (plain, merged, defs) # Add compound things: anyOf, oneOf, not, if, then, else From 8a52e589d8ac1f7279f609cf97db7fe6d7c5e888 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Wed, 29 Jan 2025 22:51:12 +0000 Subject: [PATCH 46/53] Add some dubious hacks to get appropriate errors These build on the smell of hacks from the previous commit to make this class a bit smelly. This adds some work arounds to get appropriate error messages for nodes that could be one of two types. Should we need more usage of this we probably want to refactor aspects of the ObjectFactory code to be able to handle dual types and the TypeChecker. --- .../node_factory/schema/v3_1.rb | 34 ++++++++++++++++--- .../node_factory/schema/v3_1_spec.rb | 18 ++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index e143d3b8..9984d40f 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -7,6 +7,7 @@ module Openapi3Parser module NodeFactory module Schema + # rubocop:disable Metrics/ClassLength class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCase using ArraySentence include Referenceable @@ -44,13 +45,37 @@ def boolean_input? end def errors - @errors ||= boolean_input? ? [] : super + # It's a bit janky that we do this method overloading here to handle + # the dual types of a 3.1 Schema. However this is the only node we + # have this dual type behaviour. We should do something more clever + # in the factories if there is further precedent. + @errors ||= if boolean_input? + Validation::ErrorCollection.new + elsif raw_input && !raw_input.is_a?(::Hash) + error = Validation::Error.new( + "Invalid type. Expected Object or Boolean", + context, + self.class + ) + Validation::ErrorCollection.new([error]) + else + super + end end def node(node_context) - return super unless boolean_input? - - Node::Schema::V3_1.new({ "boolean" => resolved_input }, node_context) + # as per #errors above, this is a bit of a nasty hack to handle + # dual type handling and should be refactored should there be + # other nodes with the same needs + if boolean_input? + Node::Schema::V3_1.new({ "boolean" => resolved_input }, node_context) + elsif raw_input && !raw_input.is_a?(::Hash) + raise Error::InvalidType, + "Invalid type for #{context.location_summary}: " \ + "Expected Object or Boolean" + else + super + end end def build_node(data, node_context) @@ -122,6 +147,7 @@ def prefix_items_factory(context) ) end end + # rubocop:enable Metrics/ClassLength 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 146ee836..b90993e0 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 @@ -50,6 +50,24 @@ it_behaves_like "schema factory" + describe "type validation" do + it "rejects a non object or boolean input with an appropriate explanation" do + instance = described_class.new(create_node_factory_context(15)) + + expect(instance).to have_validation_error("#/").with_message("Invalid type. Expected Object or Boolean") + end + + it "raises the appropriate error when a non object or boolean input is built" do + node_factory_context = create_node_factory_context("blah") + instance = described_class.new(node_factory_context) + node_context = node_factory_context_to_node_context(node_factory_context) + + expect { instance.node(node_context) } + .to raise_error(Openapi3Parser::Error::InvalidType, + "Invalid type for #/: Expected Object or Boolean") + end + end + describe "boolean input" do it "is valid for a boolean input" do instance = described_class.new(create_node_factory_context(false)) From 07dabbffb32b9d54b063545cc743f4824670a7ce Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 30 Jan 2025 14:44:56 +0000 Subject: [PATCH 47/53] Move additionalProperties to 3.0 Schema node This will be modelled differently for OpenAPI 3.1 where a JSON schema can itself have a boolean value and thus we won't be using Boolean or Schema anymore as a 3.1 Schema will suffice. --- lib/openapi3_parser/node/schema.rb | 13 -------- lib/openapi3_parser/node/schema/v3_0.rb | 13 ++++++++ .../node_factory/schema/common.rb | 17 ---------- .../node_factory/schema/v3_0.rb | 21 ++++++++++++- .../node_factory/schema/v3_0_spec.rb | 31 +++++++++++++++++++ spec/support/schema_common.rb | 31 ------------------- 6 files changed, 64 insertions(+), 62 deletions(-) diff --git a/lib/openapi3_parser/node/schema.rb b/lib/openapi3_parser/node/schema.rb index 44aaf67f..4f6271f4 100644 --- a/lib/openapi3_parser/node/schema.rb +++ b/lib/openapi3_parser/node/schema.rb @@ -157,19 +157,6 @@ def properties self["properties"] end - # @return [Boolean] - def additional_properties? - self["additionalProperties"] != false - end - - # @return [Schema, nil] - def additional_properties_schema - properties = self["additionalProperties"] - return if [true, false].include?(properties) - - properties - end - # @return [String, nil] def description self["description"] diff --git a/lib/openapi3_parser/node/schema/v3_0.rb b/lib/openapi3_parser/node/schema/v3_0.rb index bf0da0db..230d703d 100644 --- a/lib/openapi3_parser/node/schema/v3_0.rb +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -21,6 +21,19 @@ def exclusive_maximum? def exclusive_minimum? self["exclusiveMinimum"] end + + # @return [Boolean] + def additional_properties? + self["additionalProperties"] != false + end + + # @return [Schema, nil] + def additional_properties_schema + properties = self["additionalProperties"] + return if [true, false].include?(properties) + + properties + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/common.rb b/lib/openapi3_parser/node_factory/schema/common.rb index 1018cf14..0e2c2968 100644 --- a/lib/openapi3_parser/node_factory/schema/common.rb +++ b/lib/openapi3_parser/node_factory/schema/common.rb @@ -33,10 +33,6 @@ def self.included(base) base.field "not", factory: :referenceable_schema base.field "items", factory: :referenceable_schema base.field "properties", factory: :schema_map_factory - base.field "additionalProperties", - validate: :additional_properties_input_type, - factory: :additional_properties_factory, - default: false base.field "description", input_type: String base.field "format", input_type: String base.field "default" @@ -106,19 +102,6 @@ def referenceable_schema_array(context) value_factory: NodeFactory::Schema.factory(context) ) end - - def additional_properties_input_type(validatable) - input = validatable.input - return if [true, false].include?(input) || input.is_a?(Hash) - - validatable.add_error("Expected a Boolean or an Object") - end - - def additional_properties_factory(context) - return context.input if [true, false].include?(context.input) - - referenceable_schema(context) - end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_0.rb b/lib/openapi3_parser/node_factory/schema/v3_0.rb index 2efa68ce..df8d7e1a 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_0.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -13,10 +13,16 @@ class V3_0 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "type", input_type: String # JSON Schema 2016 has these exclusive fields as booleans whereas - # in JSON Schema 2021 (OpenAPI 3.1) these are numbers + # in JSON Schema 2020 (OpenAPI 3.1) these are numbers field "exclusiveMaximum", input_type: :boolean, default: false field "exclusiveMinimum", input_type: :boolean, default: false + # JSON Schema 2020 accepts a schema (albeit a more complex one) than + # this schema or boolean approach + field "additionalProperties", validate: :additional_properties_input_type, + factory: :additional_properties_factory, + default: false + validate :items_for_array def build_node(data, node_context) @@ -36,6 +42,19 @@ def items_for_array(validatable) validatable.add_error("items must be defined for a type of array") end + + def additional_properties_input_type(validatable) + input = validatable.input + return if [true, false].include?(input) || input.is_a?(Hash) + + validatable.add_error("Expected a Boolean or an Object") + end + + def additional_properties_factory(context) + return context.input if [true, false].include?(context.input) + + referenceable_schema(context) + end end end end diff --git a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb index ee1b92f2..3a2022fc 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb @@ -72,4 +72,35 @@ .with_message("items must be defined for a type of array") end end + + describe "validating additionalProperties" do + it "is valid for a boolean" do + true_instance = described_class.new( + create_node_factory_context({ "additionalProperties" => true }) + ) + expect(true_instance).to be_valid + + false_instance = described_class.new( + create_node_factory_context({ "additionalProperties" => false }) + ) + expect(false_instance).to be_valid + end + + it "is valid for a schema" do + instance = described_class.new( + create_node_factory_context({ "additionalProperties" => { "type" => "object" } }) + ) + expect(instance).to be_valid + end + + it "is invalid for something different" do + instance = described_class.new( + create_node_factory_context({ "additionalProperties" => %w[item1 item2] }) + ) + expect(instance).not_to be_valid + expect(instance) + .to have_validation_error("#/additionalProperties") + .with_message("Expected a Boolean or an Object") + end + end end diff --git a/spec/support/schema_common.rb b/spec/support/schema_common.rb index 90fafd9d..8956eded 100644 --- a/spec/support/schema_common.rb +++ b/spec/support/schema_common.rb @@ -88,37 +88,6 @@ expect(read_only).to be_valid end end - - describe "validating additionalProperties" do - it "is valid for a boolean" do - true_instance = described_class.new( - create_node_factory_context({ "additionalProperties" => true }) - ) - expect(true_instance).to be_valid - - false_instance = described_class.new( - create_node_factory_context({ "additionalProperties" => false }) - ) - expect(false_instance).to be_valid - end - - it "is valid for a schema" do - instance = described_class.new( - create_node_factory_context({ "additionalProperties" => { "type" => "object" } }) - ) - expect(instance).to be_valid - end - - it "is invalid for something different" do - instance = described_class.new( - create_node_factory_context({ "additionalProperties" => %w[item1 item2] }) - ) - expect(instance).not_to be_valid - expect(instance) - .to have_validation_error("#/additionalProperties") - .with_message("Expected a Boolean or an Object") - end - end end RSpec.shared_examples "schema node" do |openapi_version:| From f43d88eaaf175d11e287db6ae101609b7822f07e Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Thu, 30 Jan 2025 17:06:26 +0000 Subject: [PATCH 48/53] Add methods for boolean schemas I wasn't sure whether to add methods to the existing class or to create a new class. I'll see how this pans out. --- lib/openapi3_parser/node/schema/v3_1.rb | 36 +++++++++++++++ .../openapi3_parser/node/schema/v3_1_spec.rb | 45 ++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 4b60e994..0125274c 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -15,6 +15,42 @@ class Schema < Node::Object # these complexities and focuses on the core schema as defined in: # https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01 class V3_1 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase + # Whether this is a schema that is just a boolean value rather + # than a schema object + # + # @return [Boolean] + def boolean? + !boolean.nil? + end + + # Returns a boolean for a boolean schema [1] and nil for one based + # on an object + # + # [1]: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00#section-4.3.2 + # + # @return [Boolean, nil] + def boolean + self["boolean"] + end + + # Returns true when this is a boolean schema that has a true value, + # returns false for booleans schemas that have a false value or schemas + # that are objects. + # + # @return [Boolean] + def true? + boolean == true + end + + # Returns false when this is a boolean schema that has a false value, + # returns false for booleans schemas that have a true value or schemas + # that are objects. + # + # @return [Boolean] + def false? + boolean == false + end + # @return [String, Node::Array, nil] def type self["type"] diff --git a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb index f2bff448..3ca62135 100644 --- a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb +++ b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb @@ -3,8 +3,51 @@ RSpec.describe Openapi3Parser::Node::Schema::V3_1 do it_behaves_like "schema node", openapi_version: "3.1.0" + describe "boolean methods" do + let(:instance) do + factory_context = create_node_factory_context(input) + + Openapi3Parser::NodeFactory::Schema::V3_1 + .new(factory_context) + .node(node_factory_context_to_node_context(factory_context)) + end + + context "when given a boolean schema with a true value" do + let(:input) { true } + + it "identifies as a boolean" do + expect(instance.boolean?).to be(true) + expect(instance.boolean).to be(true) + expect(instance.true?).to be(true) + expect(instance.false?).to be(false) + end + end + + context "when given a boolean schema with a false value" do + let(:input) { false } + + it "identifies as a boolean" do + expect(instance.boolean?).to be(true) + expect(instance.boolean).to be(false) + expect(instance.true?).to be(false) + expect(instance.false?).to be(true) + end + end + + context "when given an object schema" do + let(:input) { { "type" => "string" } } + + it "does not identify as a boolean" do + expect(instance.boolean?).to be(false) + expect(instance.boolean).to be_nil + expect(instance.true?).to be(false) + expect(instance.false?).to be(false) + end + end + end + %i[if then else].each do |method_name| - describe method_name.to_s do + describe "##{method_name}" do it "supports a Ruby reserved word as a method name" do factory_context = create_node_factory_context( { method_name.to_s => { "type" => "string" } }, From b12715fe4415eee9d0602d86985620aa88dc55f8 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Mon, 3 Feb 2025 17:47:03 +0000 Subject: [PATCH 49/53] Add support for additionalProperties, unevaluatedItems and unevaluatedProperties These schema properties return schemas but are commonly used as booleans. Therefore I've added boolean helper methods for them. --- json-schema-for-3.1.md | 11 +- lib/openapi3_parser/node/schema/v3_1.rb | 36 ++++++ .../node_factory/schema/v3_1.rb | 3 + .../openapi3_parser/node/schema/v3_1_spec.rb | 107 ++++++++++++++---- 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/json-schema-for-3.1.md b/json-schema-for-3.1.md index 3da12fec..2568763a 100644 --- a/json-schema-for-3.1.md +++ b/json-schema-for-3.1.md @@ -82,15 +82,15 @@ else - single schema - done dependentSchemas - map of schemas - done prefixItems: array of schema - done -items: array of schema - in 3.0 +items: schema - in 3.0 contains: schema - done properties: object, each value json schema - in 3.0 patternProperties: object each value JSON schema key regex - done -additionalProperties: single json schema (works with the boolean schema value that we don't support) +additionalProperties: single json schema - done -unevaluatedItems - single schema - somewhat complex because of booleanSchemas -unevaluatedProperties: single schema - as above +unevaluatedItems - single schema - done +unevaluatedProperties: single schema - done ## Returning to this in 2025 @@ -112,9 +112,6 @@ Little things: - probably want a quick way to get coverage of the methods on nodes - could validate that pattern and patternProperties contain regexs -Bigger things: -- OpenAPI 3.1 supports boolean schemas that are just a true or false value, this code doesn't. - JSON Schema specs: meta: https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-00 diff --git a/lib/openapi3_parser/node/schema/v3_1.rb b/lib/openapi3_parser/node/schema/v3_1.rb index 0125274c..2147f8cc 100644 --- a/lib/openapi3_parser/node/schema/v3_1.rb +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -140,6 +140,42 @@ def contains def pattern_properties self["patternProperties"] end + + # @return [Schema, nil] + def additional_properties + self["additionalProperties"] + end + + # @return [Boolean] + def additional_properties? + return false unless additional_properties + + !additional_properties.false? + end + + # @return [Schema, nil] + def unevaluated_items + self["unevaluatedItems"] + end + + # @return [Boolean] + def unevaluated_items? + return false unless unevaluated_items + + !unevaluated_items.false? + end + + # @return [Schema, nil] + def unevaluated_properties + self["unevaluatedProperties"] + end + + # @return [Boolean] + def unevaluated_properties? + return false unless unevaluated_properties + + !unevaluated_properties.false? + end end end end diff --git a/lib/openapi3_parser/node_factory/schema/v3_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb index 9984d40f..d5a37efc 100644 --- a/lib/openapi3_parser/node_factory/schema/v3_1.rb +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -39,6 +39,9 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas field "prefixItems", factory: :prefix_items_factory field "contains", factory: :referenceable_schema field "patternProperties", factory: :schema_map_factory + field "additionalProperties", factory: :referenceable_schema + field "unevaluatedItems", factory: :referenceable_schema + field "unevaluatedProperties", factory: :referenceable_schema def boolean_input? [true, false].include?(resolved_input) diff --git a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb index 3ca62135..2df2b11f 100644 --- a/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb +++ b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb @@ -3,6 +3,85 @@ RSpec.describe Openapi3Parser::Node::Schema::V3_1 do it_behaves_like "schema node", openapi_version: "3.1.0" + shared_examples "schema presence boolean" do |method_name, property| + describe "##{method_name}" do + let(:factory_context) do + create_node_factory_context( + { property => schema }, + document_input: { + "openapi" => "3.1.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + } + } + ) + end + + let(:instance) do + Openapi3Parser::NodeFactory::Schema::V3_1 + .new(factory_context) + .node(node_factory_context_to_node_context(factory_context)) + end + + context "when a schema object is provided" do + let(:schema) { { "type" => "string" } } + + it "returns true" do + expect(instance.public_send(method_name)).to be(true) + end + end + + context "when a schema of true is provided" do + let(:schema) { true } + + it "returns true" do + expect(instance.public_send(method_name)).to be(true) + end + end + + context "when a schema of false is provided" do + let(:schema) { false } + + it "returns false" do + expect(instance.public_send(method_name)).to be(false) + end + end + + context "when no schema is provided" do + let(:schema) { nil } + + it "returns false" do + expect(instance.public_send(method_name)).to be(false) + end + end + end + end + + shared_examples "ruby keyword method" do |method_name| + describe "##{method_name}" do + it "supports a Ruby reserved word as a method name" do + factory_context = create_node_factory_context( + { method_name.to_s => { "type" => "string" } }, + document_input: { + "openapi" => "3.1.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + } + } + ) + + instance = Openapi3Parser::NodeFactory::Schema::V3_1 + .new(factory_context) + .node(node_factory_context_to_node_context(factory_context)) + + expect(instance.public_send(method_name)) + .to be_an_instance_of(described_class) + end + end + end + describe "boolean methods" do let(:instance) do factory_context = create_node_factory_context(input) @@ -46,27 +125,11 @@ end end - %i[if then else].each do |method_name| - describe "##{method_name}" do - it "supports a Ruby reserved word as a method name" do - factory_context = create_node_factory_context( - { method_name.to_s => { "type" => "string" } }, - document_input: { - "openapi" => "3.1.0", - "info" => { - "title" => "Minimal Openapi definition", - "version" => "1.0.0" - } - } - ) + include_examples "schema presence boolean", :additional_properties?, "additionalProperties" + include_examples "schema presence boolean", :unevaluated_items?, "unevaluatedItems" + include_examples "schema presence boolean", :unevaluated_properties?, "unevaluatedProperties" - instance = Openapi3Parser::NodeFactory::Schema::V3_1 - .new(factory_context) - .node(node_factory_context_to_node_context(factory_context)) - - expect(instance.public_send(method_name)) - .to be_an_instance_of(described_class) - end - end - end + include_examples "ruby keyword method", :if + include_examples "ruby keyword method", :then + include_examples "ruby keyword method", :else end From 53fae014ae5346f458f8e7ad8c7fcac000b3771f Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 14 Feb 2025 20:18:29 +0000 Subject: [PATCH 50/53] Add output of warnings to Kernel#warn This sends warnings for a document to Kernel#warn so they are output for a user. A document can be created with emit_warnings: false as a means to suppress these. Previously we only used warnings as an attribute of a document which could be accessed which meant they were quite hidden. This was based on the expectation that users are opening schemas they don't know about and thus a warning isn't helpful. However I now think for most people they are working with a schema in their codebase and thus it is better to be more explicit. --- lib/openapi3_parser.rb | 30 ++++++++---- lib/openapi3_parser/document.rb | 17 +++++-- .../document/reference_registry_spec.rb | 4 +- spec/lib/openapi3_parser/document_spec.rb | 47 ++++++++++++------- spec/lib/openapi3_parser/node/context_spec.rb | 12 ++--- .../node_factory/components_spec.rb | 3 +- .../node_factory/context_spec.rb | 10 ++-- .../node_factory/fields/reference_spec.rb | 10 ++-- .../node_factory/media_type_spec.rb | 1 + .../node_factory/oauth_flows_spec.rb | 1 + .../object_factory/node_builder_spec.rb | 3 ++ .../resolved_input_builder_spec.rb | 6 +++ .../node_factory/path_item_spec.rb | 1 + .../node_factory/reference_spec.rb | 4 +- .../node_factory/schema/v3_0_spec.rb | 1 + .../node_factory/schema/v3_1_spec.rb | 2 + .../openapi3_parser/source/location_spec.rb | 19 +++----- .../source/resolved_reference_spec.rb | 16 +++---- spec/lib/openapi3_parser/source_spec.rb | 24 +++++----- .../validation/error_collection_spec.rb | 2 +- spec/spec_helper.rb | 2 +- spec/support/helpers/context.rb | 6 ++- spec/support/helpers/source.rb | 8 ++-- 23 files changed, 138 insertions(+), 91 deletions(-) diff --git a/lib/openapi3_parser.rb b/lib/openapi3_parser.rb index e0b7d784..35f859eb 100644 --- a/lib/openapi3_parser.rb +++ b/lib/openapi3_parser.rb @@ -7,32 +7,44 @@ module Openapi3Parser # For a variety of inputs this will construct an OpenAPI document. For a # String/File input it will try to determine if the input is JSON or YAML. # - # @param [String, Hash, File] input Source for the OpenAPI document + # @param [String, Hash, File] input Source for the OpenAPI document + # @param [Boolean] emit_warnings Whether to call Kernel.warn when + # warnings are output, best set to + # false when parsing specification + # files you've not authored # # @return [Document] - def self.load(input) - Document.new(SourceInput::Raw.new(input)) + def self.load(input, emit_warnings: true) + Document.new(SourceInput::Raw.new(input), emit_warnings:) end # For a given string filename this will read the file and parse it as an # OpenAPI document. It will try detect automatically whether the contents # are JSON or YAML. # - # @param [String] path Filename of the OpenAPI document + # @param [String] path Filename of the OpenAPI document + # @param [Boolean] emit_warnings Whether to call Kernel.warn when + # warnings are output, best set to + # false when parsing specification + # files you've not authored # # @return [Document] - def self.load_file(path) - Document.new(SourceInput::File.new(path)) + def self.load_file(path, emit_warnings: true) + Document.new(SourceInput::File.new(path), emit_warnings:) end # For a given string URL this will request the resource and parse it as an # OpenAPI document. It will try detect automatically whether the contents # are JSON or YAML. # - # @param [String] url URL of the OpenAPI document + # @param [String] url URL of the OpenAPI document + # @param [Boolean] emit_warnings Whether to call Kernel.warn when + # warnings are output, best set to + # false when parsing specification + # files you've not authored # # @return [Document] - def self.load_url(url) - Document.new(SourceInput::Url.new(url.to_s)) + def self.load_url(url, emit_warnings: true) + Document.new(SourceInput::Url.new(url.to_s), emit_warnings:) end end diff --git a/lib/openapi3_parser/document.rb b/lib/openapi3_parser/document.rb index d4793f26..bcfa879d 100644 --- a/lib/openapi3_parser/document.rb +++ b/lib/openapi3_parser/document.rb @@ -9,11 +9,12 @@ module Openapi3Parser # @attr_reader [OpenapiVersion] openapi_version # @attr_reader [Source] root_source # @attr_reader [Array] warnings + # @attr_reader [Boolean] emit_warnings class Document extend Forwardable include Enumerable - attr_reader :openapi_version, :root_source, :warnings + attr_reader :openapi_version, :root_source, :warnings, :emit_warnings # A collection of the openapi versions that are supported SUPPORTED_OPENAPI_VERSIONS = %w[3.0 3.1].freeze @@ -78,10 +79,15 @@ class Document :security, :tags, :external_docs, :extension, :[], :each, :keys - # @param [SourceInput] source_input - def initialize(source_input) + # @param [SourceInput] source_input + # @param [Boolean] emit_warnings Whether to call Kernel.warn when + # warnings are output, best set to + # false when parsing specification + # files you've not authored + 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 = [] @openapi_version = determine_openapi_version(root_source.data["openapi"]) @build_in_progress = false @@ -169,6 +175,7 @@ def look_up_pointer(pointer, relative_pointer, subject) end def add_warning(text) + warn("Warning: #{text}") if emit_warnings @warnings << text end @@ -192,12 +199,12 @@ def determine_openapi_version(version) if version add_warning( "Unsupported OpenAPI version (#{version}), treating as a " \ - "#{DEFAULT_OPENAPI_VERSION} document" + "#{DEFAULT_OPENAPI_VERSION} document." ) else add_warning( "Unspecified OpenAPI version, treating as a " \ - "#{DEFAULT_OPENAPI_VERSION} document" + "#{DEFAULT_OPENAPI_VERSION} document." ) end diff --git a/spec/lib/openapi3_parser/document/reference_registry_spec.rb b/spec/lib/openapi3_parser/document/reference_registry_spec.rb index 6d52ca84..6ce8d6e9 100644 --- a/spec/lib/openapi3_parser/document/reference_registry_spec.rb +++ b/spec/lib/openapi3_parser/document/reference_registry_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Openapi3Parser::Document::ReferenceRegistry do describe "#register" do let(:source_location) do - create_source_location({ contact: { name: "John Smith" } }, + create_source_location({ openapi: "3.0.0", contact: { name: "John Smith" } }, pointer_segments: %w[contact]) end @@ -64,7 +64,7 @@ describe "#factory" do let(:object_type) { "Openapi3Parser::NodeFactory::Contact" } let(:source_location) do - create_source_location({ contact: { name: "John Smith" } }, + create_source_location({ openapi: "3.0.0", contact: { name: "John Smith" } }, pointer_segments: %w[contact]) end diff --git a/spec/lib/openapi3_parser/document_spec.rb b/spec/lib/openapi3_parser/document_spec.rb index e0d9ee6c..77ae79c5 100644 --- a/spec/lib/openapi3_parser/document_spec.rb +++ b/spec/lib/openapi3_parser/document_spec.rb @@ -35,37 +35,48 @@ def raw_source_input(data) end context "when no OpenAPI version is provided" do - let(:instance) do - described_class.new( - raw_source_input(source_data.merge("openapi" => nil)) - ) - end + let(:input) { raw_source_input(source_data.merge("openapi" => nil)) } it "treats the version as the default for the library" do - expect(instance.openapi_version) - .to eq(Openapi3Parser::Document::DEFAULT_OPENAPI_VERSION) + instance = nil + expect { instance = described_class.new(input) }.to output.to_stderr + expect(instance.openapi_version).to eq(Openapi3Parser::Document::DEFAULT_OPENAPI_VERSION) end it "has a warning" do - expect(instance.warnings).to include(/Unspecified OpenAPI version/) + instance = nil + warning = /Unspecified OpenAPI version/ + expect { instance = described_class.new(input) } + .to output(warning).to_stderr + expect(instance.warnings).to include(warning) + end + + it "doesn't output to stderr when emit_warnings is false" do + expect { described_class.new(input, emit_warnings: false) } + .not_to output.to_stderr end end context "when an unsupported OpenAPI version is provided" do - let(:instance) do - described_class.new( - raw_source_input(source_data.merge("openapi" => "2.0.0")) - ) - end + let(:input) { raw_source_input(source_data.merge("openapi" => "2.0.0")) } it "treats the version as the default for the library" do - expect(instance.openapi_version) - .to eq(Openapi3Parser::Document::DEFAULT_OPENAPI_VERSION) + instance = nil + expect { instance = described_class.new(input) }.to output.to_stderr + expect(instance.openapi_version).to eq(Openapi3Parser::Document::DEFAULT_OPENAPI_VERSION) end it "has a warning" do - expect(instance.warnings) - .to include(/Unsupported OpenAPI version #{Regexp.escape('(2.0.0)')}/) + instance = nil + warning = /Unsupported OpenAPI version #{Regexp.escape('(2.0.0)')}/ + expect { instance = described_class.new(input) } + .to output(warning).to_stderr + expect(instance.warnings).to include(warning) + end + + it "doesn't output to stderr when emit_warnings is false" do + expect { described_class.new(input, emit_warnings: false) } + .not_to output.to_stderr end end end @@ -131,7 +142,7 @@ def raw_source_input(data) end it "returns errors for invalid source data" do - instance = described_class.new(raw_source_input({})) + instance = described_class.new(raw_source_input({ "openapi" => "3.0.0" })) expect(instance.errors).not_to be_empty end diff --git a/spec/lib/openapi3_parser/node/context_spec.rb b/spec/lib/openapi3_parser/node/context_spec.rb index 2b34ad32..b2bfbc1b 100644 --- a/spec/lib/openapi3_parser/node/context_spec.rb +++ b/spec/lib/openapi3_parser/node/context_spec.rb @@ -168,11 +168,11 @@ describe "#==" do let(:document_location) do - create_source_location({}, pointer_segments: %w[field_a]) + create_source_location({ "openapi" => "3.0.0" }, pointer_segments: %w[field_a]) end let(:source_location) do - create_source_location({}, + create_source_location({ "openapi" => "3.0.0" }, document: document_location.source.document, pointer_segments: %w[ref_a]) end @@ -213,17 +213,17 @@ describe "#same_data_inputs?" do let(:source_location) do - create_source_location({}, pointer_segments: %w[ref_a]) + create_source_location({ openapi: "3.0.0" }, pointer_segments: %w[ref_a]) end let(:document_location) do - create_source_location({}, + create_source_location({ openapi: "3.0.0" }, document: source_location.source.document, pointer_segments: %w[field_a]) end let(:other_document_location) do - create_source_location({}, + create_source_location({ openapi: "3.0.0" }, document: source_location.source.document, pointer_segments: %w[field_b]) end @@ -305,7 +305,7 @@ end it "returns nil when there isn't a parent (for example at root)" do - instance = create_node_context({}, document_input: {}) + instance = create_node_context({}, document_input: { "openapi" => "3.0.0" }) expect(instance.parent_node).to be_nil end end diff --git a/spec/lib/openapi3_parser/node_factory/components_spec.rb b/spec/lib/openapi3_parser/node_factory/components_spec.rb index b35bc8d9..ae2912bd 100644 --- a/spec/lib/openapi3_parser/node_factory/components_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/components_spec.rb @@ -101,7 +101,8 @@ let(:node_factory_context) do create_node_factory_context(input, - document_input: { "components" => input }) + document_input: { "openapi" => "3.0.0", + "components" => input }) end end diff --git a/spec/lib/openapi3_parser/node_factory/context_spec.rb b/spec/lib/openapi3_parser/node_factory/context_spec.rb index f5981b52..56991524 100644 --- a/spec/lib/openapi3_parser/node_factory/context_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/context_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Openapi3Parser::NodeFactory::Context do describe ".root" do - let(:input) { {} } + let(:input) { { "openapi" => "3.0.0" } } let(:source) { create_source(input) } it "returns a context instance" do @@ -21,7 +21,7 @@ end describe ".next_field" do - let(:input) { { "key" => "value" } } + let(:input) { { "openapi" => "3.0.0", "key" => "value" } } let(:parent_context) do create_node_factory_context(input, document_input: input) end @@ -48,7 +48,7 @@ end describe ".resolved_reference" do - let(:input) { "data" } + let(:input) { { "openapi" => "3.0.0" } } let(:source_location) { create_source_location(input) } let(:reference_context) do @@ -67,7 +67,7 @@ instance = described_class.resolved_reference( reference_context, source_location: ) - expect(instance.input).to eq "data" + expect(instance.input).to eq(input) end it "has the resolved reference location" do @@ -88,7 +88,7 @@ describe "#location_summary" do it "returns a string representation of the pointer segments" do - source_location = create_source_location({}, pointer_segments: %w[path to field]) + source_location = create_source_location({ "openapi" => "3.0.0" }, pointer_segments: %w[path to field]) instance = described_class.new({}, source_location:) expect(instance.location_summary).to eq "#/path/to/field" end diff --git a/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb b/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb index 574b95c3..e91a4d6d 100644 --- a/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/fields/reference_spec.rb @@ -9,7 +9,7 @@ pointer_segments: %w[field $ref] ) end - let(:document_input) { {} } + let(:document_input) { { "openapi" => "3.0.0" } } describe "#resolved_input" do it "raises an error because a reference itself isn't resolved" do @@ -33,8 +33,8 @@ let(:instance) { described_class.new(factory_context, factory_class) } context "when the reference can be resolved" do - let(:document_input) do - { "reference" => { "name" => "Joe" } } + before do + document_input["reference"] = { "name" => "joe" } end it "is valid" do @@ -43,8 +43,8 @@ end context "when the reference can't be resolved" do - let(:document_input) do - { "reference" => { "url" => "invalid url" } } + before do + document_input["reference"] = { "url" => "invalid url" } end it "is invalid" do diff --git a/spec/lib/openapi3_parser/node_factory/media_type_spec.rb b/spec/lib/openapi3_parser/node_factory/media_type_spec.rb index df0422dc..07db4c3d 100644 --- a/spec/lib/openapi3_parser/node_factory/media_type_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/media_type_spec.rb @@ -34,6 +34,7 @@ let(:document_input) do { + "openapi" => "3.0.0", "components" => { "schemas" => { "Pet" => { diff --git a/spec/lib/openapi3_parser/node_factory/oauth_flows_spec.rb b/spec/lib/openapi3_parser/node_factory/oauth_flows_spec.rb index d71473b7..e67c6c9a 100644 --- a/spec/lib/openapi3_parser/node_factory/oauth_flows_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/oauth_flows_spec.rb @@ -12,6 +12,7 @@ let(:document_input) do { + "openapi" => "3.0.0", "myReference" => { "authorizationUrl" => "https://example.com/api/oauth/dialog", "tokenUrl" => "https://example.com/api/oauth/token", diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb index 288dccac..2f3ab68c 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/node_builder_spec.rb @@ -128,6 +128,7 @@ def default context "when the factory includes a reference to other nodes" do let(:document_input) do { + "openapi" => "3.0.0", "components" => { "schemas" => { "Referenced" => { @@ -246,6 +247,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Reference" }, document_input: { + "openapi" => "3.0.0", "components" => { "schemas" => { "Reference" => { @@ -282,6 +284,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/Referenced" }, document_input: { + "openapi" => "3.0.0", "Referenced" => { "name" => "Joe" } diff --git a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb index 50c250d8..73cad75e 100644 --- a/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb @@ -43,6 +43,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/reference_a" }, document_input: { + "openapi" => "3.0.0", "reference_a" => { "$ref" => "#/reference_b", "last_name" => "Smith" }, "reference_b" => { "first_name" => "John", "last_name" => "Doe" } } @@ -57,6 +58,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/reference_a" }, document_input: { + "openapi" => "3.0.0", "reference_a" => { "first_name" => "John", "last_name" => "Smith" } } ) @@ -69,6 +71,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/reference_a", "last_name" => nil }, document_input: { + "openapi" => "3.0.0", "reference_a" => { "first_name" => "John", "last_name" => "Smith" } } ) @@ -82,6 +85,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/reference_a" }, document_input: { + "openapi" => "3.0.0", "reference_a" => { "$ref" => "#/reference_b" }, "reference_b" => { "$ref" => "#/reference_a" } } @@ -96,6 +100,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/reference_a" }, document_input: { + "openapi" => "3.0.0", "reference_a" => 25 } ) @@ -109,6 +114,7 @@ def ref_factory(context) factory_context = create_node_factory_context( { "$ref" => "#/components/schemas/Reference" }, document_input: { + "openapi" => "3.0.0", "components" => { "schemas" => { "Reference" => { diff --git a/spec/lib/openapi3_parser/node_factory/path_item_spec.rb b/spec/lib/openapi3_parser/node_factory/path_item_spec.rb index de8ee518..0a7aa3be 100644 --- a/spec/lib/openapi3_parser/node_factory/path_item_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/path_item_spec.rb @@ -87,6 +87,7 @@ describe "merging contents with a reference" do let(:document_input) do { + "openapi" => "3.0.0", "path_items" => { "example" => { "summary" => "My summary", diff --git a/spec/lib/openapi3_parser/node_factory/reference_spec.rb b/spec/lib/openapi3_parser/node_factory/reference_spec.rb index 92dae984..04c53405 100644 --- a/spec/lib/openapi3_parser/node_factory/reference_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/reference_spec.rb @@ -6,7 +6,7 @@ def create_instance(input) factory_context = create_node_factory_context( input, - document_input: { contact: {} } + document_input: { "openapi" => "3.0.0", "contact" => {} } ) factory = Openapi3Parser::NodeFactory::Contact described_class.new(factory_context, factory) @@ -94,6 +94,7 @@ def create_node(input) factory_context = create_node_factory_context( { "$ref" => "#/contact2" }, document_input: { + openapi: "3.0.0", contact1: { "$ref" => "#/contact2" }, contact2: { "$ref" => "#/contact3" }, contact3: {} @@ -109,6 +110,7 @@ def create_node(input) factory_context = create_node_factory_context( { "$ref" => "#/contact2" }, document_input: { + openapi: "3.0.0", contact1: { "$ref" => "#/contact2" }, contact2: { "$ref" => "#/contact3" }, contact3: { "$ref" => "#/contact1" } diff --git a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb index 3a2022fc..c9d701d8 100644 --- a/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb @@ -18,6 +18,7 @@ let(:document_input) do { + "openapi" => "3.0.0", "components" => { "schemas" => { "Pet" => { 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 b90993e0..08eeae67 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 @@ -21,6 +21,7 @@ let(:document_input) do { + "openapi" => "3.1.0", "components" => { "schemas" => { "Pet" => { @@ -102,6 +103,7 @@ context "when a referenced schema is a boolean" do let(:document_input) do { + "openapi" => "3.1.0", "components" => { "schemas" => { "Bool" => true diff --git a/spec/lib/openapi3_parser/source/location_spec.rb b/spec/lib/openapi3_parser/source/location_spec.rb index 9702990b..29e1eda4 100644 --- a/spec/lib/openapi3_parser/source/location_spec.rb +++ b/spec/lib/openapi3_parser/source/location_spec.rb @@ -1,14 +1,9 @@ # frozen_string_literal: true RSpec.describe Openapi3Parser::Source::Location do - # let(:source) { create_source({}) } - # let(:document) { source.document } - # let(:pointer_segments) { %w[field] } - # let(:instance) { described_class.new(source, pointer_segments) } - describe ".next_field" do it "returns a source location relatively appened to a segment" do - source = create_source({}) + source = create_source({ "openapi" => "3.0.0" }) location = described_class.new(source, %w[field]) next_field = described_class.next_field(location, "next") @@ -17,7 +12,7 @@ end describe "#==" do - let(:source) { create_source({}) } + let(:source) { create_source({ "openapi" => "3.0.0" }) } let(:pointer_segments) { %w[field] } let(:instance) { described_class.new(source, pointer_segments) } @@ -36,7 +31,7 @@ describe "#to_s" do it "returns a fragment for a root source" do - instance = described_class.new(create_source({}), %w[path to segment]) + instance = described_class.new(create_source({ "openapi" => "3.0.0" }), %w[path to segment]) expect(instance.to_s).to eq "#/path/to/segment" end @@ -54,7 +49,7 @@ describe "#data" do it "returns the data referenced at the pointer" do - source = create_source({ field: 1234 }) + source = create_source({ openapi: "3.0.0", field: 1234 }) instance = described_class.new(source, %w[field]) expect(instance.data).to eq 1234 end @@ -62,13 +57,13 @@ describe "#pointer_defined?" do it "returns true when the pointer references defined data" do - source = create_source({ field: 1234 }) + source = create_source({ openapi: "3.0.0", field: 1234 }) instance = described_class.new(source, %w[field]) expect(instance.pointer_defined?).to be true end it "returns false when the pointer references undefined data" do - source = create_source({ field: 1234 }) + source = create_source({ openapi: "3.0.0", field: 1234 }) instance = described_class.new(source, %w[not-field]) expect(instance.pointer_defined?).to be false end @@ -78,7 +73,7 @@ let(:url) { "http://example.com/test" } let(:source) do create_source(Openapi3Parser::SourceInput::Url.new(url), - document: create_source({}).document) + document: create_source({ "openapi" => "3.0.0" }).document) end it "returns true when a source can be opened" do diff --git a/spec/lib/openapi3_parser/source/resolved_reference_spec.rb b/spec/lib/openapi3_parser/source/resolved_reference_spec.rb index d5326bef..ee375f0f 100644 --- a/spec/lib/openapi3_parser/source/resolved_reference_spec.rb +++ b/spec/lib/openapi3_parser/source/resolved_reference_spec.rb @@ -5,7 +5,7 @@ describe "#errors" do it "returns an empty array when there are no errors" do - source_location = create_source_location({ field: { name: "John" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { name: "John" } }, pointer_segments: %w[field]) reference_registry = create_reference_registry(source_location) instance = described_class.new( @@ -18,7 +18,7 @@ end it "includes an error when a source isn't available" do - source_location = create_source_location({}) + source_location = create_source_location allow(source_location.source).to receive_messages(available?: false, relative_to_root: "../openapi.yml") reference_registry = create_reference_registry(source_location) @@ -30,7 +30,7 @@ end it "includes an error when a pointer isn't in the source" do - source_location = create_source_location({ field: { name: "John" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { name: "John" } }, pointer_segments: %w[different]) reference_registry = create_reference_registry(source_location) instance = described_class.new(source_location:, @@ -41,7 +41,7 @@ end it "includes an error when the factory doesn't reference a valid object" do - source_location = create_source_location({ field: { unexpected: "Blah" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { unexpected: "Blah" } }, pointer_segments: %w[field]) reference_registry = create_reference_registry(source_location) instance = described_class.new(source_location:, @@ -54,7 +54,7 @@ describe "#valid?" do it "returns true when valid" do - source_location = create_source_location({ field: { name: "John" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { name: "John" } }, pointer_segments: %w[field]) reference_registry = create_reference_registry(source_location) instance = described_class.new(source_location:, @@ -64,7 +64,7 @@ end it "returns false when not" do - source_location = create_source_location({ field: { unexpected: "Blah" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { unexpected: "Blah" } }, pointer_segments: %w[field]) reference_registry = create_reference_registry(source_location) instance = described_class.new(source_location:, @@ -76,7 +76,7 @@ describe "#factory" do it "returns a factory for a registered reference" do - source_location = create_source_location({ field: { name: "John" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { name: "John" } }, pointer_segments: %w[field]) reference_registry = create_reference_registry(source_location) instance = described_class.new(source_location:, @@ -86,7 +86,7 @@ end it "raises an error when a reference is registered" do - source_location = create_source_location({ field: { name: "John" } }, + source_location = create_source_location({ openapi: "3.0.0", field: { name: "John" } }, pointer_segments: %w[field]) instance = described_class.new( source_location:, diff --git a/spec/lib/openapi3_parser/source_spec.rb b/spec/lib/openapi3_parser/source_spec.rb index ee17cbb9..7c54a263 100644 --- a/spec/lib/openapi3_parser/source_spec.rb +++ b/spec/lib/openapi3_parser/source_spec.rb @@ -3,24 +3,26 @@ RSpec.describe Openapi3Parser::Source do describe "#data" do it "deep-freezes the data" do - instance = create_source({ "info" => { "version" => "1.0.0" } }) + instance = create_source({ "openapi" => "3.0.0", + "info" => { "version" => "1.0.0" } }) expect(instance.data).to be_frozen expect(instance.data["info"]).to be_frozen end it "normalises symbol keys to strings" do - instance = create_source({ key: "value" }) - expect(instance.data).to eq({ "key" => "value" }) + instance = create_source({ openapi: "3.0.0" }) + expect(instance.data).to eq({ "openapi" => "3.0.0" }) end it "normalises array like data" do - instance = create_source({ "key" => Set.new([1, 2, 3]) }) - expect(instance.data).to eq({ "key" => [1, 2, 3] }) + instance = create_source({ "openapi" => "3.0.0", + "key" => Set.new([1, 2, 3]) }) + expect(instance.data["key"]).to eq([1, 2, 3]) end end describe "#resolve_reference" do - let(:instance) { create_source({}) } + let(:instance) { create_source } let(:reference) { "#/reference" } let(:unbuilt_factory) { Openapi3Parser::NodeFactory::Contact } let(:context) { create_node_factory_context({}) } @@ -51,7 +53,7 @@ end describe "#resolve_source" do - let(:instance) { create_source({}) } + let(:instance) { create_source } it "returns current source when a reference is relative" do reference = Openapi3Parser::Source::Reference.new("#/test") @@ -70,7 +72,7 @@ end describe "#data_at_pointer" do - let(:source_input) { { "info" => { "version" => "1.0.0" } } } + let(:source_input) { { "openapi" => "3.0.0", "info" => { "version" => "1.0.0" } } } let(:instance) { create_source(source_input) } it "returns the data at a given pointer" do @@ -89,7 +91,7 @@ end describe "#has_pointer?" do - let(:source_input) { { "info" => { "version" => "1.0.0" } } } + let(:source_input) { { "openapi" => "3.0.0", "info" => { "version" => "1.0.0" } } } let(:instance) { create_source(source_input) } it "returns true when there is data at a pointer" do @@ -109,7 +111,7 @@ ) document = Openapi3Parser::Document.new( - create_raw_source_input(data: {}, working_directory: "/dir-1/dir-2") + create_raw_source_input(data: { "openapi" => "3.0.0" }, working_directory: "/dir-1/dir-2") ) instance = create_source(source_input, document:) @@ -117,7 +119,7 @@ end it "returns an empty string when called on the root source" do - root_source = create_source({}) + root_source = create_source expect(root_source.relative_to_root).to eq("") end end diff --git a/spec/lib/openapi3_parser/validation/error_collection_spec.rb b/spec/lib/openapi3_parser/validation/error_collection_spec.rb index 5eba42a1..e1fb677f 100644 --- a/spec/lib/openapi3_parser/validation/error_collection_spec.rb +++ b/spec/lib/openapi3_parser/validation/error_collection_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Openapi3Parser::Validation::ErrorCollection do let(:base_document) do - source_input = Openapi3Parser::SourceInput::Raw.new({}) + source_input = Openapi3Parser::SourceInput::Raw.new({ "openapi" => "3.0.0" }) Openapi3Parser::Document.new(source_input) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 234d91a1..0d2eab0f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -15,7 +15,7 @@ config.disable_monkey_patching! - config.order = :random + # config.order = :random Kernel.srand config.seed WebMock.disable_net_connect! diff --git a/spec/support/helpers/context.rb b/spec/support/helpers/context.rb index cef21d24..c81036d8 100644 --- a/spec/support/helpers/context.rb +++ b/spec/support/helpers/context.rb @@ -3,7 +3,7 @@ module Helpers module Context def create_node_factory_context(input, - document_input: {}, + document_input: { "openapi" => "3.0.0" }, document: nil, pointer_segments: [], reference_pointer_fragments: []) @@ -39,7 +39,9 @@ def node_factory_context_to_node_context(node_factory_context) input_locations:) end - def create_node_context(input, document_input: {}, pointer_segments: []) + def create_node_context(input, + document_input: { "openapi" => "3.0.0" }, + pointer_segments: []) source_input = Openapi3Parser::SourceInput::Raw.new(document_input) document = Openapi3Parser::Document.new(source_input) location = Openapi3Parser::Source::Location.new(document.root_source, pointer_segments) diff --git a/spec/support/helpers/source.rb b/spec/support/helpers/source.rb index af500287..854930cb 100644 --- a/spec/support/helpers/source.rb +++ b/spec/support/helpers/source.rb @@ -2,14 +2,14 @@ module Helpers module Source - def create_source_location(source_input, + def create_source_location(source_input = { "openapi" => "3.0.0" }, document: nil, pointer_segments: []) source = create_source(source_input, document:) Openapi3Parser::Source::Location.new(source, pointer_segments) end - def create_source(source_input, document: nil) + def create_source(source_input = { "openapi" => "3.0.0" }, document: nil) unless source_input.is_a?(Openapi3Parser::SourceInput) source_input = Openapi3Parser::SourceInput::Raw.new(source_input) end @@ -22,7 +22,7 @@ def create_source(source_input, document: nil) end end - def create_file_source_input(data: {}, + def create_file_source_input(data: { "openapi" => "3.0.0" }, path: "/path/to/openapi.yaml", working_directory: nil) allow(File) @@ -42,7 +42,7 @@ def create_raw_source_input(data: {}, working_directory:) end - def create_url_source_input(data: {}, + def create_url_source_input(data: { "openapi" => "3.0.0" }, url: "https://example.com/openapi.yaml") stub_request(:get, url) .to_return(body: data.to_yaml, status: 200) From 221577273e7ff6ca637f1ef3ece303c7b6e48917 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 21 Feb 2025 16:04:47 +0000 Subject: [PATCH 51/53] Rename Validators::Url to Validators::Uri This doesn't anything specific for a URL. --- lib/openapi3_parser/node_factory/contact.rb | 4 ++-- lib/openapi3_parser/node_factory/example.rb | 4 ++-- .../node_factory/external_documentation.rb | 4 ++-- lib/openapi3_parser/node_factory/info.rb | 4 ++-- lib/openapi3_parser/node_factory/license.rb | 4 ++-- lib/openapi3_parser/node_factory/openapi.rb | 4 ++++ lib/openapi3_parser/validators/{url.rb => uri.rb} | 4 ++-- spec/lib/openapi3_parser/validators/uri_spec.rb | 15 +++++++++++++++ spec/lib/openapi3_parser/validators/url_spec.rb | 15 --------------- 9 files changed, 31 insertions(+), 27 deletions(-) rename lib/openapi3_parser/validators/{url.rb => uri.rb} (77%) create mode 100644 spec/lib/openapi3_parser/validators/uri_spec.rb delete mode 100644 spec/lib/openapi3_parser/validators/url_spec.rb diff --git a/lib/openapi3_parser/node_factory/contact.rb b/lib/openapi3_parser/node_factory/contact.rb index a5fd6b77..7e205f6a 100644 --- a/lib/openapi3_parser/node_factory/contact.rb +++ b/lib/openapi3_parser/node_factory/contact.rb @@ -3,7 +3,7 @@ require "openapi3_parser/node_factory/object" require "openapi3_parser/validation/input_validator" require "openapi3_parser/validators/email" -require "openapi3_parser/validators/url" +require "openapi3_parser/validators/uri" module Openapi3Parser module NodeFactory @@ -13,7 +13,7 @@ class Contact < NodeFactory::Object field "name", input_type: String field "url", input_type: String, - validate: Validation::InputValidator.new(Validators::Url) + validate: Validation::InputValidator.new(Validators::Uri) field "email", input_type: String, validate: Validation::InputValidator.new(Validators::Email) diff --git a/lib/openapi3_parser/node_factory/example.rb b/lib/openapi3_parser/node_factory/example.rb index c711b932..7f551767 100644 --- a/lib/openapi3_parser/node_factory/example.rb +++ b/lib/openapi3_parser/node_factory/example.rb @@ -2,7 +2,7 @@ require "openapi3_parser/node_factory/object" require "openapi3_parser/validation/input_validator" -require "openapi3_parser/validators/url" +require "openapi3_parser/validators/uri" module Openapi3Parser module NodeFactory @@ -14,7 +14,7 @@ class Example < NodeFactory::Object field "value" field "externalValue", input_type: String, - validate: Validation::InputValidator.new(Validators::Url) + validate: Validation::InputValidator.new(Validators::Uri) mutually_exclusive "value", "externalValue" diff --git a/lib/openapi3_parser/node_factory/external_documentation.rb b/lib/openapi3_parser/node_factory/external_documentation.rb index ac921f75..0119f136 100644 --- a/lib/openapi3_parser/node_factory/external_documentation.rb +++ b/lib/openapi3_parser/node_factory/external_documentation.rb @@ -2,7 +2,7 @@ require "openapi3_parser/node_factory/object" require "openapi3_parser/validation/input_validator" -require "openapi3_parser/validators/url" +require "openapi3_parser/validators/uri" module Openapi3Parser module NodeFactory @@ -13,7 +13,7 @@ class ExternalDocumentation < NodeFactory::Object field "url", required: true, input_type: String, - validate: Validation::InputValidator.new(Validators::Url) + validate: Validation::InputValidator.new(Validators::Uri) def build_node(data, node_context) Node::ExternalDocumentation.new(data, node_context) diff --git a/lib/openapi3_parser/node_factory/info.rb b/lib/openapi3_parser/node_factory/info.rb index ea369786..2b113d0d 100644 --- a/lib/openapi3_parser/node_factory/info.rb +++ b/lib/openapi3_parser/node_factory/info.rb @@ -4,7 +4,7 @@ require "openapi3_parser/node_factory/license" require "openapi3_parser/node_factory/object" require "openapi3_parser/validation/input_validator" -require "openapi3_parser/validators/url" +require "openapi3_parser/validators/uri" module Openapi3Parser module NodeFactory @@ -17,7 +17,7 @@ class Info < NodeFactory::Object field "description", input_type: String field "termsOfService", input_type: String, - validate: Validation::InputValidator.new(Validators::Url) + validate: Validation::InputValidator.new(Validators::Uri) field "contact", factory: NodeFactory::Contact field "license", factory: NodeFactory::License field "version", input_type: String, required: true diff --git a/lib/openapi3_parser/node_factory/license.rb b/lib/openapi3_parser/node_factory/license.rb index 56eb892c..6ea319a0 100644 --- a/lib/openapi3_parser/node_factory/license.rb +++ b/lib/openapi3_parser/node_factory/license.rb @@ -2,7 +2,7 @@ require "openapi3_parser/node_factory/object" require "openapi3_parser/validation/input_validator" -require "openapi3_parser/validators/url" +require "openapi3_parser/validators/uri" module Openapi3Parser module NodeFactory @@ -14,7 +14,7 @@ class License < NodeFactory::Object allowed: ->(context) { context.openapi_version >= "3.1" } field "url", input_type: String, - validate: Validation::InputValidator.new(Validators::Url) + validate: Validation::InputValidator.new(Validators::Uri) mutually_exclusive "identifier", "url" def build_node(data, node_context) diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index 6d25b805..256b1df6 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -13,6 +13,10 @@ 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", + input_type: String, + allowed: ->(context) { context.openapi_version >= "3.1" } field "servers", factory: :servers_factory field "paths", factory: NodeFactory::Paths, diff --git a/lib/openapi3_parser/validators/url.rb b/lib/openapi3_parser/validators/uri.rb similarity index 77% rename from lib/openapi3_parser/validators/url.rb rename to lib/openapi3_parser/validators/uri.rb index 533e1b02..6067d2c2 100644 --- a/lib/openapi3_parser/validators/url.rb +++ b/lib/openapi3_parser/validators/uri.rb @@ -2,11 +2,11 @@ module Openapi3Parser module Validators - class Url + class Uri def self.call(input) URI.parse(input) && nil rescue URI::InvalidURIError - %("#{input}" is not a valid URL) + %("#{input}" is not a valid URI) end end end diff --git a/spec/lib/openapi3_parser/validators/uri_spec.rb b/spec/lib/openapi3_parser/validators/uri_spec.rb new file mode 100644 index 00000000..61f33263 --- /dev/null +++ b/spec/lib/openapi3_parser/validators/uri_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::Validators::Uri do + describe ".call" do + it "returns nil for a valid URI" do + expect(described_class.call("https://example.org/resource")) + .to be_nil + end + + it "returns an error for an invalid URI" do + expect(described_class.call("not a URI")) + .to eq %("not a URI" is not a valid URI) + end + end +end diff --git a/spec/lib/openapi3_parser/validators/url_spec.rb b/spec/lib/openapi3_parser/validators/url_spec.rb deleted file mode 100644 index ff8e0fa2..00000000 --- a/spec/lib/openapi3_parser/validators/url_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Openapi3Parser::Validators::Url do - describe ".call" do - it "returns nil for a valid URL" do - expect(described_class.call("https://example.org/resource")) - .to be_nil - end - - it "returns an error for an invalid URL" do - expect(described_class.call("not a URL")) - .to eq %("not a URL" is not a valid URL) - end - end -end From a4d6dbb9230ac3d73006d0e31709f029d9248e31 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 21 Feb 2025 17:17:36 +0000 Subject: [PATCH 52/53] Add jsonSchemaDialect field to openapi node This is a field introduced in OpenAPI 3.1 and it defaults to the OpenAPI 3.1 dialect. My intention is to only support this dialect for OpenAPI 3.1. --- lib/openapi3_parser/document.rb | 12 ++++++++---- lib/openapi3_parser/node/openapi.rb | 7 +++++++ lib/openapi3_parser/node_factory/openapi.rb | 1 + .../node_factory/openapi_spec.rb | 18 +++++++++++++++++- spec/support/examples/v3.1/changes.yaml | 8 ++++---- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/openapi3_parser/document.rb b/lib/openapi3_parser/document.rb index bcfa879d..311ab476 100644 --- a/lib/openapi3_parser/document.rb +++ b/lib/openapi3_parser/document.rb @@ -38,6 +38,10 @@ class Document # The value of the info field on the OpenAPI document # @see Node::Openapi#info # @return [Node::Info] + # @!method jsonSchemaDialect + # The value of the jsonSchemaDialect field on the OpenAPI document + # @see Node::Openapi#json_schema_dialect + # @return [String, nil] # @!method servers # The value of the servers field on the OpenAPI document # @see Node::Openapi#servers @@ -75,9 +79,9 @@ class Document # Iterate through the attributes of the root object # @!method keys # Access keys of the root object - def_delegators :root, :openapi, :info, :servers, :paths, :components, - :security, :tags, :external_docs, :extension, :[], :each, - :keys + def_delegators :root, :openapi, :info, :json_schema_dialect, :servers, + :paths, :components, :security, :tags, :external_docs, + :extension, :[], :each, :keys # @param [SourceInput] source_input # @param [Boolean] emit_warnings Whether to call Kernel.warn when @@ -175,7 +179,7 @@ def look_up_pointer(pointer, relative_pointer, subject) end def add_warning(text) - warn("Warning: #{text}") if emit_warnings + warn("Warning: #{text} - disable these by opening a document with emit_warnings: false") if emit_warnings @warnings << text end diff --git a/lib/openapi3_parser/node/openapi.rb b/lib/openapi3_parser/node/openapi.rb index 6b57265d..bec6c1b5 100644 --- a/lib/openapi3_parser/node/openapi.rb +++ b/lib/openapi3_parser/node/openapi.rb @@ -17,6 +17,13 @@ def info self["info"] end + # The default jsonSchemaDialect for this document + # + # @return [String, nil] + def json_schema_dialect + self["jsonSchemaDialect"] + end + # @return [Node::Array] def servers self["servers"] diff --git a/lib/openapi3_parser/node_factory/openapi.rb b/lib/openapi3_parser/node_factory/openapi.rb index 256b1df6..71ff4274 100644 --- a/lib/openapi3_parser/node_factory/openapi.rb +++ b/lib/openapi3_parser/node_factory/openapi.rb @@ -16,6 +16,7 @@ class Openapi < NodeFactory::Object field "jsonSchemaDialect", default: "https://spec.openapis.org/oas/3.1/dialect/base", input_type: String, + validate: Validation::InputValidator.new(Validators::Uri), allowed: ->(context) { context.openapi_version >= "3.1" } field "servers", factory: :servers_factory field "paths", diff --git a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb index 1afa9f07..fe5cacf2 100644 --- a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb @@ -165,10 +165,26 @@ .to have_validation_error("#/") .with_message("At least one of components, paths and webhooks fields are required") end + + it "accepts a jsonSchemaDialect field" do + factory_context = create_node_factory_context( + minimal_openapi_definition.merge({ "openapi" => "3.1.0", + "jsonSchemaDialect" => "uri://to/dialect" }), + document_input: { "openapi" => "3.1.0" } + ) + + instance = described_class.new(factory_context) + expect(instance).to be_valid + end + + it "defaults to the OAS 3.1 jsonSchemaDialect" do + node = create_node(minimal_openapi_definition.merge({ "openapi" => "3.1.0" })) + expect(node.json_schema_dialect).to eq("https://spec.openapis.org/oas/3.1/dialect/base") + end end def create_node(input) - node_factory_context = create_node_factory_context(input) + node_factory_context = create_node_factory_context(input, document_input: { openapi: input["openapi"] }) instance = described_class.new(node_factory_context) node_context = node_factory_context_to_node_context(node_factory_context) instance.node(node_context) diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml index ba8bc159..a6251330 100644 --- a/spec/support/examples/v3.1/changes.yaml +++ b/spec/support/examples/v3.1/changes.yaml @@ -3,10 +3,10 @@ info: title: Examples of changes in OpenAPI 3.1 summary: 3.1 introduced a summary field to the Info node version: 1.0.0 - # license: - # name: Apache 2.0 - # identifier: Apache-2.0 -# jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base + license: + name: Apache 2.0 + identifier: Apache-2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base components: examples: FullExample: From 0292afff66e5d29ed4247d7e47326bcf3ec39c58 Mon Sep 17 00:00:00 2001 From: Kevin Dew Date: Fri, 21 Feb 2025 23:05:10 +0000 Subject: [PATCH 53/53] JSON schema dialect warnings This adds a system in place to emit warnings if a schema is parsed and it doesn't have the OAS schema dialect for OpenAPI 3.1. My expectation is very few people will consider using over schema dialects and likely no-one will need this message, but if you did it could be quite confusing if this gem just seems to perform oddly with a different schema dialect. As this was relatively uncharted territory for this gem I've had to be a bit creative with implementing this. Without a clear place to hook this in, I've added it to the validation route of schemas. Since schemas are lazily parsed, I've made warnings for a document be a lazy loaded attribute that forces a validation run first so it can provide a complete list. I didn't want this gem to be super annoying and output warnings on every schema so I've configured this to only warn once per unsupported schema dialect. --- TODO.md | 4 +- lib/openapi3_parser/document.rb | 34 ++++- lib/openapi3_parser/node/schema/v3_1.rb | 10 ++ lib/openapi3_parser/node_factory/openapi.rb | 3 +- .../node_factory/schema/v3_1.rb | 16 +++ spec/integration/open_v3.1_examples_spec.rb | 27 ++++ spec/lib/openapi3_parser/document_spec.rb | 63 ++++++++++ .../node_factory/schema/v3_1_spec.rb | 116 ++++++++++++++++++ .../v3.1/schema-dialects-example.yaml | 20 +++ 9 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 spec/support/examples/v3.1/schema-dialects-example.yaml diff --git a/TODO.md b/TODO.md index 6ba43b48..d6cd98ff 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 311ab476..cee2eb2c 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 2147f8cc..dab869eb 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 71ff4274..2ed72823 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 d5a37efc..4efe15f0 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 38cb6cdb..a6213f43 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 77ae79c5..b8b1f63b 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 08eeae67..2f203054 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 00000000..d4758382 --- /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