diff --git a/TODO.md b/TODO.md index d943e7fb..d6cd98ff 100644 --- a/TODO.md +++ b/TODO.md @@ -39,3 +39,25 @@ 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 +- [x] No longer require responses field on an Operation node +- [x] Require OpenAPI node to have webhooks, paths or components +- [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 +- [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 +- [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 +- [x] Discriminator object can be extended +- [x] 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..2568763a --- /dev/null +++ b/json-schema-for-3.1.md @@ -0,0 +1,118 @@ +# 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 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? +- 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 - in 3.0 +$dynamicRef +$defs +$schema +$id +$comment +$anchor +$dynamicAnchor + +Then a ton of fields: + +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 - done +minimum: 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 +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 - in 3.0 (missing unique) +dependentRequired: something complex done +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 +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 - done +then - single schema - done +else - single schema - done +dependentSchemas - map of schemas - done + +prefixItems: array of schema - done +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 - done + +unevaluatedItems - single schema - done +unevaluatedProperties: single schema - done + + +## 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. + +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: + +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 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 be8f6ea2..cee2eb2c 100644 --- a/lib/openapi3_parser/document.rb +++ b/lib/openapi3_parser/document.rb @@ -6,17 +6,19 @@ 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 + # @attr_reader [Boolean] emit_warnings + # rubocop:disable Metrics/ClassLength class Document extend Forwardable include Enumerable - attr_reader :openapi_version, :root_source, :warnings + attr_reader :openapi_version, :root_source, :emit_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 @@ -37,6 +39,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 @@ -74,15 +80,21 @@ 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 - - # @param [SourceInput] source_input - def initialize(source_input) + 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 + # 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) - @warnings = [] + @emit_warnings = emit_warnings + @build_warnings = [] + @unsupported_schema_dialects = Set.new @openapi_version = determine_openapi_version(root_source.data["openapi"]) @build_in_progress = false @built = false @@ -152,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, @@ -169,7 +201,8 @@ def look_up_pointer(pointer, relative_pointer, subject) end def add_warning(text) - @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 @@ -179,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 @@ -187,21 +219,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} document." ) - DEFAULT_OPENAPI_VERSION else add_warning( "Unspecified OpenAPI version, treating as a " \ - "#{DEFAULT_OPENAPI_VERSION} document" + "#{DEFAULT_OPENAPI_VERSION} document." ) - DEFAULT_OPENAPI_VERSION end + + OpenapiVersion.new(DEFAULT_OPENAPI_VERSION) end def factory @@ -214,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/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 e805433d..13f29d11 100644 --- a/lib/openapi3_parser/node/context.rb +++ b/lib/openapi3_parser/node/context.rb @@ -14,16 +14,22 @@ 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 # # @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:, + source_locations: [source_location], + input_locations:) end # Create a context for the child of a previous context @@ -38,9 +44,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:) end # Create a context for a the a field that is the result of a reference @@ -49,36 +62,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:) + 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 + + def self.input_location?(input) + return true unless input.respond_to?(:keys) + + input.keys != ["$ref"] end - attr_reader :input, :document_location, :source_location + attr_reader :input, :document_location, :source_locations, :input_locations - # @param input - # @param [Source::Location] document_location - # @param [Source::Location] source_location - def initialize(input, document_location:, source_location:) + # @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 +128,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 +154,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 @@ -154,6 +203,14 @@ def parent_node relative_node("#..") end + + # Returns the version of OpenAPI being used + # + # @return [String] + def openapi_version + document.openapi_version + end end + # rubocop:enable Metrics/ClassLength end end 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/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/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/openapi.rb b/lib/openapi3_parser/node/openapi.rb index ced3c9fc..bec6c1b5 100644 --- a/lib/openapi3_parser/node/openapi.rb +++ b/lib/openapi3_parser/node/openapi.rb @@ -17,16 +17,28 @@ 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"] end - # @return [Paths] + # @return [Paths, nil] 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/schema.rb b/lib/openapi3_parser/node/schema.rb index 87bd7083..4f6271f4 100644 --- a/lib/openapi3_parser/node/schema.rb +++ b/lib/openapi3_parser/node/schema.rb @@ -4,7 +4,9 @@ module Openapi3Parser module Node - # @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject + # 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 @@ -34,7 +36,7 @@ class Schema < Node::Object # # @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 @@ -48,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"] @@ -119,7 +111,7 @@ def required # @param [String, Schema] property # @return [Boolean] def requires?(property) - if property.is_a?(Schema) + 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 @@ -135,11 +127,6 @@ def enum self["enum"] end - # @return [String, nil] - def type - self["type"] - end - # @return [Node::Array, nil] def all_of self["allOf"] @@ -170,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 new file mode 100644 index 00000000..230d703d --- /dev/null +++ b/lib/openapi3_parser/node/schema/v3_0.rb @@ -0,0 +1,40 @@ +# 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.0.0.md#schemaObject + class V3_0 < Schema # rubocop:disable Naming/ClassAndModuleCamelCase + # @return [String, nil] + def type + self["type"] + end + + # @return [Boolean] + def exclusive_maximum? + self["exclusiveMaximum"] + end + + # @return [Boolean] + 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 +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..dab869eb --- /dev/null +++ b/lib/openapi3_parser/node/schema/v3_1.rb @@ -0,0 +1,192 @@ +# 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 + # 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 + + # 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"] + end + + # @return [Any] + 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"] + end + + # @return [Integer] + def min_contains + self["minContains"] + end + + # @return [Node::Array] + def examples + self["examples"] + end + + # @return [Node::Map>] + def dependent_required + self["dependentRequired"] + 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 + + # @return [Schema, nil] + def if + self["if"] + end + + # @return [Schema, nil] + def then + self["then"] + end + + # @return [Schema, nil] + def else + self["else"] + end + + # @return [Node::Map] + def dependent_schemas + self["dependentSchemas"] + end + + # @return [Node::Array] + def prefix_items + self["prefixItems"] + end + + # @return [Schema, nil] + def contains + self["contains"] + end + + # @return [Node::Map] + 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 +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 07f7538f..9faa647a 100644 --- a/lib/openapi3_parser/node_factory/components.rb +++ b/lib/openapi3_parser/node_factory/components.rb @@ -15,15 +15,22 @@ 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" } - 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) - 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) @@ -58,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/lib/openapi3_parser/node_factory/contact.rb b/lib/openapi3_parser/node_factory/contact.rb index 7c1a34f0..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,15 +13,13 @@ 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) - 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/context.rb b/lib/openapi3_parser/node_factory/context.rb index d8771e73..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 @@ -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/node_factory/discriminator.rb b/lib/openapi3_parser/node_factory/discriminator.rb index 8d9a953c..577c275f 100644 --- a/lib/openapi3_parser/node_factory/discriminator.rb +++ b/lib/openapi3_parser/node_factory/discriminator.rb @@ -5,17 +5,19 @@ 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, 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..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,14 +14,12 @@ 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" - 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..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,12 +13,10 @@ class ExternalDocumentation < NodeFactory::Object field "url", required: true, input_type: String, - validate: Validation::InputValidator.new(Validators::Url) + validate: Validation::InputValidator.new(Validators::Uri) - 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 e5fa4a0e..2b113d0d 100644 --- a/lib/openapi3_parser/node_factory/info.rb +++ b/lib/openapi3_parser/node_factory/info.rb @@ -4,25 +4,26 @@ 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 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, - 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 - 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..6ea319a0 100644 --- a/lib/openapi3_parser/node_factory/license.rb +++ b/lib/openapi3_parser/node_factory/license.rb @@ -2,21 +2,23 @@ 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 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) - - private + validate: Validation::InputValidator.new(Validators::Uri) + mutually_exclusive "identifier", "url" - 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 35affa1a..495b1f8d 100644 --- a/lib/openapi3_parser/node_factory/media_type.rb +++ b/lib/openapi3_parser/node_factory/media_type.rb @@ -14,15 +14,14 @@ 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) - factory = NodeFactory::Schema - NodeFactory::OptionalReference.new(factory).call(context) + NodeFactory::Schema.build_factory(context) end def examples_factory(context) 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 73628585..afc2c81a 100644 --- a/lib/openapi3_parser/node_factory/object.rb +++ b/lib/openapi3_parser/node_factory/object.rb @@ -11,6 +11,7 @@ class Object def_delegators "self.class", :field_configs, + :extension_regex, :allowed_extensions?, :mutually_exclusive_fields, :allowed_default?, @@ -28,7 +29,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 +45,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? @@ -60,12 +66,12 @@ 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| - memo << key if config.required? + allowed_field_configs.each_with_object([]) do |(key, config), memo| + memo << key if config.required?(context, self) end end @@ -75,6 +81,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 +93,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? @@ -91,25 +101,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/dsl.rb b/lib/openapi3_parser/node_factory/object_factory/dsl.rb index 5a86f2a9..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 @@ -17,16 +18,25 @@ def field_configs @field_configs ||= {} end - def allow_extensions - @allow_extensions = true + def allow_extensions(regex: EXTENSION_REGEX, &block) + @extension_regex = regex + @allowed_extensions = block || true end - def allowed_extensions? - if instance_variable_defined?(:@allow_extensions) - @allow_extensions == true - else - false - 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 + @extension_regex ||= nil end def mutually_exclusive(*fields, required: false) 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..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,8 +37,30 @@ def initialize_factory(context, parent_factory = nil) end end - def required? - given_required + 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 + 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) @@ -71,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/lib/openapi3_parser/node_factory/object_factory/node_builder.rb b/lib/openapi3_parser/node_factory/object_factory/node_builder.rb index 2878fb14..4d98ead0 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.is_a?(::Hash) + 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..b89d7a85 --- /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| + return factory.data unless factory.data.is_a?(::Hash) + + 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/object_factory/validator.rb b/lib/openapi3_parser/node_factory/object_factory/validator.rb index bae090bb..e1a99ba2 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, - allow_extensions: factory.allowed_extensions?, + 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 c9ae4a3a..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 @@ -13,23 +14,40 @@ class Openapi < NodeFactory::Object field "openapi", input_type: String, required: true field "info", factory: NodeFactory::Info, required: true + field "jsonSchemaDialect", + default: Schema::V3_1::OAS_DIALECT, + input_type: String, + validate: Validation::InputValidator.new(Validators::Uri), + allowed: ->(context) { context.openapi_version >= "3.1" } 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 "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 field "externalDocs", factory: NodeFactory::ExternalDocumentation + validate do |validatable| + next if validatable.context.openapi_version < "3.1" + 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 + 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" => "/" }], @@ -37,6 +55,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) diff --git a/lib/openapi3_parser/node_factory/operation.rb b/lib/openapi3_parser/node_factory/operation.rb index bdff9233..c07b2207 100644 --- a/lib/openapi3_parser/node_factory/operation.rb +++ b/lib/openapi3_parser/node_factory/operation.rb @@ -16,20 +16,20 @@ 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 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/parameter_like.rb b/lib/openapi3_parser/node_factory/parameter_like.rb index fd6349be..a6b8425d 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.build_factory(context) end def examples_factory(context) 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..20c8bd41 100644 --- a/lib/openapi3_parser/node_factory/reference.rb +++ b/lib/openapi3_parser/node_factory/reference.rb @@ -1,11 +1,20 @@ # 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 + 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 @@ -14,47 +23,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..52408f0e --- /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.is_a?(::Hash) + + data["$ref"]&.self_referencing? + end + + def referenced_factory + return unless data.is_a?(::Hash) + + 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.rb b/lib/openapi3_parser/node_factory/schema.rb index d5759999..d4b73355 100644 --- a/lib/openapi3_parser/node_factory/schema.rb +++ b/lib/openapi3_parser/node_factory/schema.rb @@ -1,128 +1,24 @@ # 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) + if context.openapi_version >= "3.1" + V3_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/common.rb b/lib/openapi3_parser/node_factory/schema/common.rb new file mode 100644 index 00000000..0e2c2968 --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/common.rb @@ -0,0 +1,108 @@ +# 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: 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 + 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: :schema_map_factory + 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 :read_only_or_write_only + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + + private + + 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 schema_map_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 + 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 new file mode 100644 index 00000000..df8d7e1a --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/v3_0.rb @@ -0,0 +1,61 @@ +# 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 + include Schema::Common + + allow_extensions + # 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 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) + 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 + + 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_1.rb b/lib/openapi3_parser/node_factory/schema/v3_1.rb new file mode 100644 index 00000000..4efe15f0 --- /dev/null +++ b/lib/openapi3_parser/node_factory/schema/v3_1.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +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 + module NodeFactory + module Schema + # rubocop:disable Metrics/ClassLength + 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 + 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 + field "exclusiveMinimum", input_type: Numeric + field "maxContains", input_type: Integer + field "minContains", input_type: Integer, default: 1 + field "examples", factory: NodeFactory::Array + field "dependentRequired", factory: :dependent_required_factory + field "contentEncoding", input_type: String + field "contentMediaType", + 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 + field "dependentSchemas", factory: :schema_map_factory + 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 + + 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 + + def errors + # 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) + # 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) + 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 + + 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 + + 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, + value_factory: NodeFactory::Schema.factory(context) + ) + end + end + # rubocop:enable Metrics/ClassLength + end + end +end 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 9cafe534..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) @@ -20,14 +24,10 @@ 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 - - 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/openapi_version.rb b/lib/openapi3_parser/openapi_version.rb new file mode 100644 index 00000000..cf902564 --- /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/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/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/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/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/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/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/integration/open_v3.1_examples_spec.rb b/spec/integration/open_v3.1_examples_spec.rb new file mode 100644 index 00000000..a6213f43 --- /dev/null +++ b/spec/integration/open_v3.1_examples_spec.rb @@ -0,0 +1,90 @@ +# 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 + + 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") } + + 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 + + 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 + + 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/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..b8b1f63b 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 @@ -212,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/context_spec.rb b/spec/lib/openapi3_parser/node/context_spec.rb index e605ede6..b2bfbc1b 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,63 +34,145 @@ 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] + ) + + reference_source_location = create_source_location({}, + document: current_context.document, + pointer_segments: %w[reference]) - Openapi3Parser::NodeFactory::Context.new( + 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 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 @@ -84,10 +180,12 @@ it "returns true when input and locations match" do instance = described_class.new({}, document_location:, - source_location:) + source_locations: [source_location], + input_locations: [source_location]) other = described_class.new({}, document_location:, - source_location:) + source_locations: [source_location], + input_locations: [source_location]) expect(instance).to eq(other) end @@ -95,7 +193,8 @@ it "returns false when one of these differ" do instance = described_class.new({}, document_location:, - source_location:) + source_locations: [source_location], + input_locations: [source_location]) other_source_location = create_source_location( {}, @@ -105,49 +204,67 @@ other = described_class.new({}, document_location:, - source_location: other_source_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]) + 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 - 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:) + 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:) + 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_and_source?(other)).to be false + 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:, + 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_inputs?(other)).to be false end end @@ -188,8 +305,16 @@ 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 + + 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/schema/v3_0_spec.rb b/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb new file mode 100644 index 00000000..bd868171 --- /dev/null +++ b/spec/lib/openapi3_parser/node/schema/v3_0_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +RSpec.describe Openapi3Parser::Node::Schema::V3_0 do + 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..2df2b11f --- /dev/null +++ b/spec/lib/openapi3_parser/node/schema/v3_1_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +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) + + 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 + + include_examples "schema presence boolean", :additional_properties?, "additionalProperties" + include_examples "schema presence boolean", :unevaluated_items?, "unevaluatedItems" + include_examples "schema presence boolean", :unevaluated_properties?, "unevaluatedProperties" + + include_examples "ruby keyword method", :if + include_examples "ruby keyword method", :then + include_examples "ruby keyword method", :else +end diff --git a/spec/lib/openapi3_parser/node/schema_spec.rb b/spec/lib/openapi3_parser/node/schema_spec.rb deleted file mode 100644 index ac9ead34..00000000 --- a/spec/lib/openapi3_parser/node/schema_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Openapi3Parser::Node::Schema 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 - .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 - .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 -end diff --git a/spec/lib/openapi3_parser/node_factory/components_spec.rb b/spec/lib/openapi3_parser/node_factory/components_spec.rb index 17907a77..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 @@ -131,4 +132,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 diff --git a/spec/lib/openapi3_parser/node_factory/context_spec.rb b/spec/lib/openapi3_parser/node_factory/context_spec.rb index 2baaf543..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 @@ -112,9 +112,26 @@ 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 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:) + expect(instance.openapi_version).to eq("3.0") + end + end end 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 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..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,29 +9,13 @@ pointer_segments: %w[field $ref] ) end + let(:document_input) { { "openapi" => "3.0.0" } } 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 @@ -66,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 @@ -76,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/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 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 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/field_config_spec.rb b/spec/lib/openapi3_parser/node_factory/object_factory/field_config_spec.rb index 62c0093c..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 @@ -55,15 +55,65 @@ 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: lambda(&: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 - 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: lambda(&: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 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..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 @@ -1,104 +1,324 @@ # 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 + { + "openapi" => "3.0.0", + "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: + ) + + 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: + ) + + 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: + ) + + 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: + ) + + 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: { + "openapi" => "3.0.0", + "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: { + "openapi" => "3.0.0", + "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..73cad75e --- /dev/null +++ b/spec/lib/openapi3_parser/node_factory/object_factory/resolved_input_builder_spec.rb @@ -0,0 +1,149 @@ +# 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 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 + end + end + + it "merges data from factories together" do + 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" } + } + ) + 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: { + "openapi" => "3.0.0", + "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: { + "openapi" => "3.0.0", + "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: { + "openapi" => "3.0.0", + "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 the value if any of the referenced data isn't a hash" do + factory_context = create_node_factory_context( + { "$ref" => "#/reference_a" }, + document_input: { + "openapi" => "3.0.0", + "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" }, + document_input: { + "openapi" => "3.0.0", + "components" => { + "schemas" => { + "Reference" => { + "type" => "object", + "properties" => { + "recursive" => { "$ref" => "#/components/schemas/Reference" } + } + } + } + } + } + ) + + factory = Openapi3Parser::NodeFactory::Schema::V3_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/object_spec.rb b/spec/lib/openapi3_parser/node_factory/object_spec.rb index d611ebe9..952b121b 100644 --- a/spec/lib/openapi3_parser/node_factory/object_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/object_spec.rb @@ -5,4 +5,43 @@ let(:instance) { described_class.new(node_factory_context) } 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 diff --git a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb index 0bddbe13..fe5cacf2 100644 --- a/spec/lib/openapi3_parser/node_factory/openapi_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/openapi_spec.rb @@ -93,8 +93,98 @@ 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.0" do + it "is valid without the paths parameter" do + factory_context = create_node_factory_context( + { + "openapi" => "3.1.0", + "info" => { + "title" => "Minimal Openapi definition", + "version" => "1.0.0" + }, + "components" => {} + }, + document_input: { "openapi" => "3.1.0" } + ) + + 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 + + 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/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 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..0a7aa3be 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 { @@ -85,6 +87,7 @@ describe "merging contents with a reference" do let(:document_input) do { + "openapi" => "3.0.0", "path_items" => { "example" => { "summary" => "My summary", @@ -119,12 +122,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 +133,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..04c53405 100644 --- a/spec/lib/openapi3_parser/node_factory/reference_spec.rb +++ b/spec/lib/openapi3_parser/node_factory/reference_spec.rb @@ -1,10 +1,12 @@ # 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, - document_input: { contact: {} } + document_input: { "openapi" => "3.0.0", "contact" => {} } ) factory = Openapi3Parser::NodeFactory::Contact described_class.new(factory_context, factory) @@ -88,17 +90,11 @@ 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" }, document_input: { + openapi: "3.0.0", contact1: { "$ref" => "#/contact2" }, contact2: { "$ref" => "#/contact3" }, contact3: {} @@ -107,13 +103,14 @@ 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 factory_context = create_node_factory_context( { "$ref" => "#/contact2" }, document_input: { + openapi: "3.0.0", contact1: { "$ref" => "#/contact2" }, contact2: { "$ref" => "#/contact3" }, contact3: { "$ref" => "#/contact1" } @@ -122,7 +119,85 @@ 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 + + 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/lib/openapi3_parser/node_factory/schema_spec.rb b/spec/lib/openapi3_parser/node_factory/schema/v3_0_spec.rb similarity index 52% 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..c9d701d8 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" => [ @@ -18,6 +18,7 @@ let(:document_input) do { + "openapi" => "3.0.0", "components" => { "schemas" => { "Pet" => { @@ -45,54 +46,7 @@ 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 + it_behaves_like "schema factory" describe "validating items" do it "is valid when type is 'array' and items are provided" do @@ -120,42 +74,6 @@ 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( 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 new file mode 100644 index 00000000..2f203054 --- /dev/null +++ b/spec/lib/openapi3_parser/node_factory/schema/v3_1_spec.rb @@ -0,0 +1,401 @@ +# frozen_string_literal: true + +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::V3_1 do + let(:input) do + { + "allOf" => [ + { "$ref" => "#/components/schemas/Pet" }, + { + "type" => "object", + "properties" => { + "bark" => { "type" => "string" } + } + } + ] + } + end + + let(:document_input) do + { + "openapi" => "3.1.0", + "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:) + end + + let(:node_context) do + node_factory_context_to_node_context(node_factory_context) + end + end + + 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)) + + 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 + { + "openapi" => "3.1.0", + "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 "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| + 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 + + 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 + + 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 + + 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 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 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/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 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 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/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 diff --git a/spec/support/examples/v3.1/changes.yaml b/spec/support/examples/v3.1/changes.yaml new file mode 100644 index 00000000..a6251330 --- /dev/null +++ b/spec/support/examples/v3.1/changes.yaml @@ -0,0 +1,150 @@ +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: + 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" + 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" + 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 + DependentRequired: + type: object + properties: + name: + type: string + credit_card: + type: number + billing_address: + type: string + required: + - name + 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 + 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 + Object: + type: object + patternProperties: + /^test/: + type: string + 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 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/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 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 diff --git a/spec/support/helpers/context.rb b/spec/support/helpers/context.rb index 34a6eaea..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: []) @@ -29,23 +29,28 @@ 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:) 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 - ) + 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:) end end end 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) diff --git a/spec/support/node_equality.rb b/spec/support/node_equality.rb index 0a3742d4..da57dd1c 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:, + input_locations: source_locations ) other = described_class.new(input, other_context) diff --git a/spec/support/schema_common.rb b/spec/support/schema_common.rb new file mode 100644 index 00000000..8956eded --- /dev/null +++ b/spec/support/schema_common.rb @@ -0,0 +1,186 @@ +# 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 "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 +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