Skip to content

Commit

Permalink
JSON schema dialect warnings
Browse files Browse the repository at this point in the history
This adds a system in place to emit warnings if a schema is parsed and
it doesn't have the OAS schema dialect for OpenAPI 3.1.

My expectation is very few people will consider using over schema
dialects and likely no-one will need this message, but if you did it
could be quite confusing if this gem just seems to perform oddly with a
different schema dialect.

As this was relatively uncharted territory for this gem I've had to be a
bit creative with implementing this. Without a clear place to hook this
in, I've added it to the validation route of schemas. Since schemas are
lazily parsed, I've made warnings for a document be a lazy loaded
attribute that forces a validation run first so it can provide a
complete list.

I didn't want this gem to be super annoying and output warnings on every
schema so I've configured this to only warn once per unsupported schema
dialect.
  • Loading branch information
kevindew committed Feb 24, 2025
1 parent a4d6dbb commit 0292aff
Show file tree
Hide file tree
Showing 9 changed files with 284 additions and 9 deletions.
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ For OpenAPI 3.1
- [x] Support webhooks
- [x] No longer require responses field on an Operation node
- [x] Require OpenAPI node to have webhooks, paths or components
- [ ] Support the switch to a fixed schema dialect
- [x] Support the switch to a fixed schema dialect
- [x] Support summary field on Info node
- [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes
- [ ] jsonSchemaDialect should default to OAS one
- [x] jsonSchemaDialect should default to OAS one
- [x] Allow summary and description in Reference objects
- [x] Add identifier to License node, make mutually exclusive with URL
- [x] ServerVariable enum must not be empty
Expand Down
34 changes: 28 additions & 6 deletions lib/openapi3_parser/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ module Openapi3Parser
# @attr_reader [Source] root_source
# @attr_reader [Array<String>] warnings
# @attr_reader [Boolean] emit_warnings
# rubocop:disable Metrics/ClassLength
class Document
extend Forwardable
include Enumerable

attr_reader :openapi_version, :root_source, :warnings, :emit_warnings
attr_reader :openapi_version, :root_source, :emit_warnings

# A collection of the openapi versions that are supported
SUPPORTED_OPENAPI_VERSIONS = %w[3.0 3.1].freeze
Expand Down Expand Up @@ -92,7 +93,8 @@ def initialize(source_input, emit_warnings: true)
@reference_registry = ReferenceRegistry.new
@root_source = Source.new(source_input, self, reference_registry)
@emit_warnings = emit_warnings
@warnings = []
@build_warnings = []
@unsupported_schema_dialects = Set.new
@openapi_version = determine_openapi_version(root_source.data["openapi"])
@build_in_progress = false
@built = false
Expand Down Expand Up @@ -162,15 +164,35 @@ def node_at(pointer, relative_to = nil)
look_up_pointer(pointer, relative_to, root)
end

# An array of any warnings enountered in the initialisation / validation
# of the document. Reflects warnings related to this gems ability to parse
# the document.
#
# @return [Array<String>]
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,
Expand All @@ -179,8 +201,8 @@ def look_up_pointer(pointer, relative_pointer, subject)
end

def add_warning(text)
warn("Warning: #{text} - disable these by opening a document with emit_warnings: false") if emit_warnings
@warnings << text
warn("Warning: #{text} Disable these warnings by opening a document with emit_warnings: false.") if emit_warnings
@build_warnings << text
end

def build
Expand All @@ -190,7 +212,6 @@ def build
context = NodeFactory::Context.root(root_source.data, root_source)
@factory = NodeFactory::Openapi.new(context)
reference_registry.freeze
@warnings.freeze
@build_in_progress = false
@built = true
end
Expand Down Expand Up @@ -225,4 +246,5 @@ def reference_factories
reference_registry.factories.reject { |f| f.context.source.root? }
end
end
# rubocop:enable Metrics/ClassLength
end
10 changes: 10 additions & 0 deletions lib/openapi3_parser/node/schema/v3_1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ def false?
boolean == false
end

# The schema dialect in usage, only https://spec.openapis.org/oas/3.1/dialect/base
# is officially supported so others will receive a warning, but as
# long they don't have different data types for keywords they'll be
# mostly usable.
#
# @return [String]
def json_schema_dialect
self["$schema"] || node_context.document.json_schema_dialect
end

# @return [String, Node::Array<String>, nil]
def type
self["type"]
Expand Down
3 changes: 2 additions & 1 deletion lib/openapi3_parser/node_factory/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,7 +15,7 @@ class Openapi < NodeFactory::Object
field "openapi", input_type: String, required: true
field "info", factory: NodeFactory::Info, required: true
field "jsonSchemaDialect",
default: "https://spec.openapis.org/oas/3.1/dialect/base",
default: Schema::V3_1::OAS_DIALECT,
input_type: String,
validate: Validation::InputValidator.new(Validators::Uri),
allowed: ->(context) { context.openapi_version >= "3.1" }
Expand Down
16 changes: 16 additions & 0 deletions lib/openapi3_parser/node_factory/schema/v3_1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "openapi3_parser/node_factory/object"
require "openapi3_parser/node_factory/referenceable"
require "openapi3_parser/node_factory/schema/common"
require "openapi3_parser/validators/media_type"

module Openapi3Parser
Expand All @@ -13,12 +14,16 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas
include Referenceable
include Schema::Common
JSON_SCHEMA_ALLOWED_TYPES = %w[null boolean object array number string integer].freeze
OAS_DIALECT = "https://spec.openapis.org/oas/3.1/dialect/base"

# Allows any extension as per:
# https://github.com/OAI/OpenAPI-Specification/blob/a1facce1b3621df3630cb692e9fbe18a7612ea6d/versions/3.1.0.md#fixed-fields-20
allow_extensions(regex: /.*/)

field "$ref", input_type: String, factory: :ref_factory
field "$schema",
input_type: String,
validate: Validation::InputValidator.new(Validators::Uri)
field "type", factory: :type_factory, validate: :validate_type
field "const"
field "exclusiveMaximum", input_type: Numeric
Expand All @@ -43,6 +48,17 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas
field "unevaluatedItems", factory: :referenceable_schema
field "unevaluatedProperties", factory: :referenceable_schema

validate do |validatable|
# if we do more with supporting $schema we probably want it to be
# a value in the context object so it can cascade appropariately
document = validatable.context.source_location.document
dialect = validatable.input["$schema"] || document.resolved_input_at("#/jsonSchemaDialect")

next if dialect.nil? || dialect == OAS_DIALECT

document.unsupported_schema_dialect(dialect.to_s)
end

def boolean_input?
[true, false].include?(resolved_input)
end
Expand Down
27 changes: 27 additions & 0 deletions spec/integration/open_v3.1_examples_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@
end
end

context "when using the schema dialects example" do
let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "schema-dialects-example.yaml") }

it "is valid but outputs warnings" do
expect { document.valid? }.to output.to_stderr
expect(document).to be_valid
end

it "only warns once per dialect" do
expect { document.warnings }.to output.to_stderr
end

it "defaults to using the the jsonSchemaDialect value" do
expect { document.warnings }.to output.to_stderr
expect(document.components.schemas["DefaultDialect"].json_schema_dialect)
.to eq(document.json_schema_dialect)
end

it "can return the other schema dialects" do
expect { document.warnings }.to output.to_stderr
expect(document.components.schemas["DefinedDialect"].json_schema_dialect)
.to eq("https://spec.openapis.org/oas/3.1/dialect/base")
expect(document.components.schemas["CustomDialect1"].json_schema_dialect)
.to eq("https://example.com/custom-dialect")
end
end

context "when using the schema I created to demonstrate changes" do
let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "changes.yaml") }

Expand Down
63 changes: 63 additions & 0 deletions spec/lib/openapi3_parser/document_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,67 @@ def raw_source_input(data)
.to eq("1.0.0")
end
end

describe "#warnings" do
it "returns a frozen array" do
instance = described_class.new(raw_source_input(source_data))
expect(instance.warnings).to be_frozen
end

it "has warnings from the input" do
source_data.merge!({
"openapi" => "3.1.0",
"components" => {
"schemas" => {
"SchemaThatWillGenerateWarning" => { "$schema" => "https://example.com/unsupported-dialect" }
}
}
})

instance = described_class.new(raw_source_input(source_data))
warnings = nil
# expect a warn to be emit
expect { warnings = instance.warnings }.to output.to_stderr
expect(warnings).to include(/Unsupported schema dialect/)
end
end

describe "#unsupported_schema_dialect" do
let(:schema_dialect) { "path/to/dialect" }
let(:warning) { "Unsupported schema dialect (#{schema_dialect}), it may not parse or validate correctly." }

it "adds a warning and outputs it" do
instance = described_class.new(raw_source_input(source_data))
expect { instance.unsupported_schema_dialect(schema_dialect) }
.to output(/Unsupported schema dialect/).to_stderr

expect(instance.warnings).to include(warning)
end

it "adds a warning without outputting it if emit_warnings is false" do
instance = described_class.new(raw_source_input(source_data), emit_warnings: false)
expect { instance.unsupported_schema_dialect(schema_dialect) }
.not_to output.to_stderr

expect(instance.warnings).to include(warning)
end

it "does nothing if the schema dialect has already been registered" do
instance = described_class.new(raw_source_input(source_data), emit_warnings: false)
instance.unsupported_schema_dialect(schema_dialect)

expect { instance.unsupported_schema_dialect(schema_dialect) }
.not_to(change { instance.warnings.count })
end

it "does nothing if warnings have already been frozen" do
instance = described_class.new(raw_source_input(source_data), emit_warnings: false)
instance.unsupported_schema_dialect(schema_dialect)
# accessing warnings will ensure it's frozen
expect(instance.warnings).to be_frozen

expect { instance.unsupported_schema_dialect("other") }
.not_to(change { instance.warnings.count })
end
end
end
Loading

0 comments on commit 0292aff

Please sign in to comment.