Skip to content

Commit 0292aff

Browse files
committed
JSON schema dialect warnings
This adds a system in place to emit warnings if a schema is parsed and it doesn't have the OAS schema dialect for OpenAPI 3.1. My expectation is very few people will consider using over schema dialects and likely no-one will need this message, but if you did it could be quite confusing if this gem just seems to perform oddly with a different schema dialect. As this was relatively uncharted territory for this gem I've had to be a bit creative with implementing this. Without a clear place to hook this in, I've added it to the validation route of schemas. Since schemas are lazily parsed, I've made warnings for a document be a lazy loaded attribute that forces a validation run first so it can provide a complete list. I didn't want this gem to be super annoying and output warnings on every schema so I've configured this to only warn once per unsupported schema dialect.
1 parent a4d6dbb commit 0292aff

File tree

9 files changed

+284
-9
lines changed

9 files changed

+284
-9
lines changed

TODO.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,10 @@ For OpenAPI 3.1
4646
- [x] Support webhooks
4747
- [x] No longer require responses field on an Operation node
4848
- [x] Require OpenAPI node to have webhooks, paths or components
49-
- [ ] Support the switch to a fixed schema dialect
49+
- [x] Support the switch to a fixed schema dialect
5050
- [x] Support summary field on Info node
5151
- [ ] Create a maxi OpenAPI 3.1 integration test to collate all the known changes
52-
- [ ] jsonSchemaDialect should default to OAS one
52+
- [x] jsonSchemaDialect should default to OAS one
5353
- [x] Allow summary and description in Reference objects
5454
- [x] Add identifier to License node, make mutually exclusive with URL
5555
- [x] ServerVariable enum must not be empty

lib/openapi3_parser/document.rb

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ module Openapi3Parser
1010
# @attr_reader [Source] root_source
1111
# @attr_reader [Array<String>] warnings
1212
# @attr_reader [Boolean] emit_warnings
13+
# rubocop:disable Metrics/ClassLength
1314
class Document
1415
extend Forwardable
1516
include Enumerable
1617

17-
attr_reader :openapi_version, :root_source, :warnings, :emit_warnings
18+
attr_reader :openapi_version, :root_source, :emit_warnings
1819

1920
# A collection of the openapi versions that are supported
2021
SUPPORTED_OPENAPI_VERSIONS = %w[3.0 3.1].freeze
@@ -92,7 +93,8 @@ def initialize(source_input, emit_warnings: true)
9293
@reference_registry = ReferenceRegistry.new
9394
@root_source = Source.new(source_input, self, reference_registry)
9495
@emit_warnings = emit_warnings
95-
@warnings = []
96+
@build_warnings = []
97+
@unsupported_schema_dialects = Set.new
9698
@openapi_version = determine_openapi_version(root_source.data["openapi"])
9799
@build_in_progress = false
98100
@built = false
@@ -162,15 +164,35 @@ def node_at(pointer, relative_to = nil)
162164
look_up_pointer(pointer, relative_to, root)
163165
end
164166

167+
# An array of any warnings enountered in the initialisation / validation
168+
# of the document. Reflects warnings related to this gems ability to parse
169+
# the document.
170+
#
171+
# @return [Array<String>]
172+
def warnings
173+
@warnings ||= begin
174+
factory.errors # ensure factory has completed validation
175+
@build_warnings.freeze
176+
end
177+
end
178+
165179
# @return [String]
166180
def inspect
167181
%{#{self.class.name}(openapi_version: #{openapi_version}, } +
168182
%{root_source: #{root_source.inspect})}
169183
end
170184

185+
#  :nodoc:
186+
def unsupported_schema_dialect(schema_dialect)
187+
return if @build_warnings.frozen? || unsupported_schema_dialects.include?(schema_dialect)
188+
189+
unsupported_schema_dialects << schema_dialect
190+
add_warning("Unsupported schema dialect (#{schema_dialect}), it may not parse or validate correctly.")
191+
end
192+
171193
private
172194

173-
attr_reader :reference_registry, :built, :build_in_progress
195+
attr_reader :reference_registry, :built, :build_in_progress, :unsupported_schema_dialects, :build_warnings
174196

175197
def look_up_pointer(pointer, relative_pointer, subject)
176198
merged_pointer = Source::Pointer.merge_pointers(relative_pointer,
@@ -179,8 +201,8 @@ def look_up_pointer(pointer, relative_pointer, subject)
179201
end
180202

181203
def add_warning(text)
182-
warn("Warning: #{text} - disable these by opening a document with emit_warnings: false") if emit_warnings
183-
@warnings << text
204+
warn("Warning: #{text} Disable these warnings by opening a document with emit_warnings: false.") if emit_warnings
205+
@build_warnings << text
184206
end
185207

186208
def build
@@ -190,7 +212,6 @@ def build
190212
context = NodeFactory::Context.root(root_source.data, root_source)
191213
@factory = NodeFactory::Openapi.new(context)
192214
reference_registry.freeze
193-
@warnings.freeze
194215
@build_in_progress = false
195216
@built = true
196217
end
@@ -225,4 +246,5 @@ def reference_factories
225246
reference_registry.factories.reject { |f| f.context.source.root? }
226247
end
227248
end
249+
# rubocop:enable Metrics/ClassLength
228250
end

lib/openapi3_parser/node/schema/v3_1.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ def false?
5151
boolean == false
5252
end
5353

54+
# The schema dialect in usage, only https://spec.openapis.org/oas/3.1/dialect/base
55+
# is officially supported so others will receive a warning, but as
56+
# long they don't have different data types for keywords they'll be
57+
# mostly usable.
58+
#
59+
# @return [String]
60+
def json_schema_dialect
61+
self["$schema"] || node_context.document.json_schema_dialect
62+
end
63+
5464
# @return [String, Node::Array<String>, nil]
5565
def type
5666
self["type"]

lib/openapi3_parser/node_factory/openapi.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "openapi3_parser/node_factory/paths"
66
require "openapi3_parser/node_factory/components"
77
require "openapi3_parser/node_factory/external_documentation"
8+
require "openapi3_parser/node_factory/schema/v3_1"
89

910
module Openapi3Parser
1011
module NodeFactory
@@ -14,7 +15,7 @@ class Openapi < NodeFactory::Object
1415
field "openapi", input_type: String, required: true
1516
field "info", factory: NodeFactory::Info, required: true
1617
field "jsonSchemaDialect",
17-
default: "https://spec.openapis.org/oas/3.1/dialect/base",
18+
default: Schema::V3_1::OAS_DIALECT,
1819
input_type: String,
1920
validate: Validation::InputValidator.new(Validators::Uri),
2021
allowed: ->(context) { context.openapi_version >= "3.1" }

lib/openapi3_parser/node_factory/schema/v3_1.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "openapi3_parser/node_factory/object"
44
require "openapi3_parser/node_factory/referenceable"
5+
require "openapi3_parser/node_factory/schema/common"
56
require "openapi3_parser/validators/media_type"
67

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

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

2123
field "$ref", input_type: String, factory: :ref_factory
24+
field "$schema",
25+
input_type: String,
26+
validate: Validation::InputValidator.new(Validators::Uri)
2227
field "type", factory: :type_factory, validate: :validate_type
2328
field "const"
2429
field "exclusiveMaximum", input_type: Numeric
@@ -43,6 +48,17 @@ class V3_1 < NodeFactory::Object # rubocop:disable Naming/ClassAndModuleCamelCas
4348
field "unevaluatedItems", factory: :referenceable_schema
4449
field "unevaluatedProperties", factory: :referenceable_schema
4550

51+
validate do |validatable|
52+
# if we do more with supporting $schema we probably want it to be
53+
# a value in the context object so it can cascade appropariately
54+
document = validatable.context.source_location.document
55+
dialect = validatable.input["$schema"] || document.resolved_input_at("#/jsonSchemaDialect")
56+
57+
next if dialect.nil? || dialect == OAS_DIALECT
58+
59+
document.unsupported_schema_dialect(dialect.to_s)
60+
end
61+
4662
def boolean_input?
4763
[true, false].include?(resolved_input)
4864
end

spec/integration/open_v3.1_examples_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@
3333
end
3434
end
3535

36+
context "when using the schema dialects example" do
37+
let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "schema-dialects-example.yaml") }
38+
39+
it "is valid but outputs warnings" do
40+
expect { document.valid? }.to output.to_stderr
41+
expect(document).to be_valid
42+
end
43+
44+
it "only warns once per dialect" do
45+
expect { document.warnings }.to output.to_stderr
46+
end
47+
48+
it "defaults to using the the jsonSchemaDialect value" do
49+
expect { document.warnings }.to output.to_stderr
50+
expect(document.components.schemas["DefaultDialect"].json_schema_dialect)
51+
.to eq(document.json_schema_dialect)
52+
end
53+
54+
it "can return the other schema dialects" do
55+
expect { document.warnings }.to output.to_stderr
56+
expect(document.components.schemas["DefinedDialect"].json_schema_dialect)
57+
.to eq("https://spec.openapis.org/oas/3.1/dialect/base")
58+
expect(document.components.schemas["CustomDialect1"].json_schema_dialect)
59+
.to eq("https://example.com/custom-dialect")
60+
end
61+
end
62+
3663
context "when using the schema I created to demonstrate changes" do
3764
let(:path) { File.join(__dir__, "..", "support", "examples", "v3.1", "changes.yaml") }
3865

spec/lib/openapi3_parser/document_spec.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,67 @@ def raw_source_input(data)
223223
.to eq("1.0.0")
224224
end
225225
end
226+
227+
describe "#warnings" do
228+
it "returns a frozen array" do
229+
instance = described_class.new(raw_source_input(source_data))
230+
expect(instance.warnings).to be_frozen
231+
end
232+
233+
it "has warnings from the input" do
234+
source_data.merge!({
235+
"openapi" => "3.1.0",
236+
"components" => {
237+
"schemas" => {
238+
"SchemaThatWillGenerateWarning" => { "$schema" => "https://example.com/unsupported-dialect" }
239+
}
240+
}
241+
})
242+
243+
instance = described_class.new(raw_source_input(source_data))
244+
warnings = nil
245+
# expect a warn to be emit
246+
expect { warnings = instance.warnings }.to output.to_stderr
247+
expect(warnings).to include(/Unsupported schema dialect/)
248+
end
249+
end
250+
251+
describe "#unsupported_schema_dialect" do
252+
let(:schema_dialect) { "path/to/dialect" }
253+
let(:warning) { "Unsupported schema dialect (#{schema_dialect}), it may not parse or validate correctly." }
254+
255+
it "adds a warning and outputs it" do
256+
instance = described_class.new(raw_source_input(source_data))
257+
expect { instance.unsupported_schema_dialect(schema_dialect) }
258+
.to output(/Unsupported schema dialect/).to_stderr
259+
260+
expect(instance.warnings).to include(warning)
261+
end
262+
263+
it "adds a warning without outputting it if emit_warnings is false" do
264+
instance = described_class.new(raw_source_input(source_data), emit_warnings: false)
265+
expect { instance.unsupported_schema_dialect(schema_dialect) }
266+
.not_to output.to_stderr
267+
268+
expect(instance.warnings).to include(warning)
269+
end
270+
271+
it "does nothing if the schema dialect has already been registered" do
272+
instance = described_class.new(raw_source_input(source_data), emit_warnings: false)
273+
instance.unsupported_schema_dialect(schema_dialect)
274+
275+
expect { instance.unsupported_schema_dialect(schema_dialect) }
276+
.not_to(change { instance.warnings.count })
277+
end
278+
279+
it "does nothing if warnings have already been frozen" do
280+
instance = described_class.new(raw_source_input(source_data), emit_warnings: false)
281+
instance.unsupported_schema_dialect(schema_dialect)
282+
# accessing warnings will ensure it's frozen
283+
expect(instance.warnings).to be_frozen
284+
285+
expect { instance.unsupported_schema_dialect("other") }
286+
.not_to(change { instance.warnings.count })
287+
end
288+
end
226289
end

0 commit comments

Comments
 (0)