Skip to content

Commit 4f3c025

Browse files
committed
OpenAPI 3.1
This adds support for the OpenAPI 3.1 schema dialect (`discriminator` keyword) as well as a schema for validating OpenAPI 3.1 documents. The dialect is a direct copy of the published schema, similar to the regular JSON Schema drafts. `discriminator` is not well defined in the spec and `skip_ref_once!` is a little janky, but I think this should work and at least it's contained within that vocab. The document schema is modeled after the one from this article: http://json-schema.org/blog/posts/validating-openapi-and-json-schema with support for all of the existing JSON Schema dialects. There's some complexity around `jsonSchemaDialect` and `$schema` which is meant to support validating embedded schemas using the defined (or default) dialect. It works well for OpenAPI 3.1 and Draft 2020-12 schemas because they both use `"dynamicAnchor": "meta"`, but earlier drafts will have trouble with nested schemas that use a different `$schema`. Subschemas also use `jsonSchemaDialect` instead of inheriting `$schema` from their parent, so it's probably best to set an explicit `$schema`. `JSONSchemer.openapi` returns a simple `JSONSchemer::OpenAPI` helper object with methods for validating the document and for using embedded schemas to validate data. I'm not sure exactly what will be useful for people so the API may change in the future.
1 parent 279c88b commit 4f3c025

File tree

15 files changed

+2808
-18
lines changed

15 files changed

+2808
-18
lines changed

README.md

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
# JSONSchemer
22

3-
JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, and 2020-12.
4-
5-
## Next
6-
7-
- [ ] openapi
3+
JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, 2020-12, and OpenAPI 3.1.
84

95
## Installation
106

@@ -123,6 +119,7 @@ JSONSchemer.schema(
123119
# 'http://json-schema.org/draft-06/schema#': JSONSchemer.draft6
124120
# 'http://json-schema.org/draft-04/schema#': JSONSchemer.draft4
125121
# 'http://json-schema.org/schema#': JSONSchemer.draft4
122+
# 'https://spec.openapis.org/oas/3.1/dialect/base': JSONSchemer.openapi31
126123
# default: JSONSchemer.draft202012
127124
meta_schema: 'https://json-schema.org/draft/2020-12/schema',
128125

@@ -171,6 +168,55 @@ JSONSchemer.schema(
171168
)
172169
```
173170

171+
## OpenAPI 3.1
172+
173+
```ruby
174+
document = JSONSchemer.openapi({
175+
'openapi' => '3.1.0',
176+
'info' => {
177+
'title' => 'example'
178+
},
179+
'components' => {
180+
'schemas' => {
181+
'example' => {
182+
'type' => 'integer'
183+
}
184+
}
185+
}
186+
})
187+
188+
# document validation using meta schema
189+
190+
document.valid?
191+
# => false
192+
193+
document.validate.to_a
194+
# => [{"data"=>{"title"=>"example"},
195+
# "data_pointer"=>"/info",
196+
# "schema"=>{...info schema},
197+
# "schema_pointer"=>"/$defs/info",
198+
# "root_schema"=>{...meta schema},
199+
# "type"=>"required",
200+
# "details"=>{"missing_keys"=>["version"]}},
201+
# ...]
202+
203+
# data validation using schema by name (in `components/schemas`)
204+
205+
document.schema('example').valid?(1)
206+
# => true
207+
208+
document.schema('example').valid?('one')
209+
# => false
210+
211+
# data validation using schema by ref
212+
213+
document.ref('#/components/schemas/example').valid?(1)
214+
# => true
215+
216+
document.ref('#/components/schemas/example').valid?('one')
217+
# => false
218+
```
219+
174220
## CLI
175221

176222
The `json_schemer` executable takes a JSON schema file as the first argument followed by one or more JSON data files to validate. If there are any validation errors, it outputs them and returns an error code.

json_schemer.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
99
spec.authors = ["David Harsha"]
1010
spec.email = ["[email protected]"]
1111

12-
spec.summary = "JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, and 2020-12."
12+
spec.summary = "JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, 2020-12, and OpenAPI 3.1."
1313
spec.homepage = "https://github.com/davishmcclurg/json_schemer"
1414
spec.license = "MIT"
1515

lib/json_schemer.rb

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,24 @@
4848
require 'json_schemer/draft4/meta'
4949
require 'json_schemer/draft4/vocab/validation'
5050
require 'json_schemer/draft4/vocab'
51+
require 'json_schemer/openapi31/meta'
52+
require 'json_schemer/openapi31/vocab/base'
53+
require 'json_schemer/openapi31/vocab'
54+
require 'json_schemer/openapi31/document'
55+
require 'json_schemer/openapi'
5156
require 'json_schemer/schema'
5257

5358
module JSONSchemer
5459
class UnsupportedMetaSchema < StandardError; end
60+
class UnsupportedOpenAPIVersion < StandardError; end
5561
class UnknownRef < StandardError; end
5662
class UnknownFormat < StandardError; end
5763
class UnknownVocabulary < StandardError; end
5864
class UnknownContentEncoding < StandardError; end
5965
class UnknownContentMediaType < StandardError; end
6066
class UnknownOutputFormat < StandardError; end
6167
class InvalidRefResolution < StandardError; end
68+
class InvalidRefPointer < StandardError; end
6269
class InvalidRegexpResolution < StandardError; end
6370
class InvalidFileURI < StandardError; end
6471
class InvalidSymbolKey < StandardError; end
@@ -83,7 +90,9 @@ class InvalidEcmaRegexp < StandardError; end
8390

8491
'json-schemer://draft7' => Draft7::Vocab::ALL,
8592
'json-schemer://draft6' => Draft6::Vocab::ALL,
86-
'json-schemer://draft4' => Draft4::Vocab::ALL
93+
'json-schemer://draft4' => Draft4::Vocab::ALL,
94+
95+
'https://spec.openapis.org/oas/3.1/vocab/base' => OpenAPI31::Vocab::BASE
8796
}
8897
VOCABULARY_ORDER = VOCABULARIES.transform_values.with_index { |_vocabulary, index| index }
8998

@@ -171,6 +180,35 @@ def draft4
171180
:regexp_resolver => 'ecma'
172181
)
173182
end
183+
184+
def openapi31
185+
@openapi31 ||= Schema.new(
186+
OpenAPI31::SCHEMA,
187+
:base_uri => OpenAPI31::BASE_URI,
188+
:ref_resolver => OpenAPI31::Meta::SCHEMAS.to_proc,
189+
:regexp_resolver => 'ecma',
190+
# https://spec.openapis.org/oas/latest.html#data-types
191+
:formats => {
192+
'int32' => proc { |instance, _value| instance.is_a?(Integer) && instance.bit_length <= 32 },
193+
'int64' => proc { |instance, _value| instance.is_a?(Integer) && instance.bit_length <= 64 },
194+
'float' => proc { |instance, _value| instance.is_a?(Float) },
195+
'double' => proc { |instance, _value| instance.is_a?(Float) },
196+
'password' => proc { |_instance, _value| true }
197+
}
198+
)
199+
end
200+
201+
def openapi31_document
202+
@openapi31_document ||= Schema.new(
203+
OpenAPI31::Document::SCHEMA_BASE,
204+
:ref_resolver => OpenAPI31::Document::SCHEMAS.to_proc,
205+
:regexp_resolver => 'ecma'
206+
)
207+
end
208+
209+
def openapi(document, **options)
210+
OpenAPI.new(document, **options)
211+
end
174212
end
175213

176214
META_SCHEMA_CALLABLES_BY_BASE_URI_STR = {
@@ -180,7 +218,8 @@ def draft4
180218
Draft6::BASE_URI.to_s => method(:draft6),
181219
Draft4::BASE_URI.to_s => method(:draft4),
182220
# version-less $schema deprecated after Draft 4
183-
'http://json-schema.org/schema#' => method(:draft4)
221+
'http://json-schema.org/schema#' => method(:draft4),
222+
OpenAPI31::BASE_URI.to_s => method(:openapi31)
184223
}.freeze
185224

186225
META_SCHEMAS_BY_BASE_URI_STR = Hash.new do |hash, base_uri_str|

lib/json_schemer/draft202012/vocab/core.rb

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,28 @@ def parse
116116
class Comment < Keyword; end
117117

118118
class UnknownKeyword < Keyword
119-
def schema!
120-
subschema(value)
119+
def parse
120+
if value.is_a?(Hash)
121+
{}
122+
elsif value.is_a?(Array)
123+
[]
124+
else
125+
value
126+
end
127+
end
128+
129+
def fetch_unknown!(token)
130+
if value.is_a?(Hash)
131+
parsed[token] ||= JSONSchemer::Schema::UNKNOWN_KEYWORD_CLASS.new(value.fetch(token), self, token, schema)
132+
elsif value.is_a?(Array)
133+
parsed[token.to_i] ||= JSONSchemer::Schema::UNKNOWN_KEYWORD_CLASS.new(value.fetch(token.to_i), self, token, schema)
134+
else
135+
raise KeyError.new(:receiver => parsed, :key => token)
136+
end
137+
end
138+
139+
def unknown_schema!
140+
@unknown_schema ||= subschema(value)
121141
end
122142

123143
def validate(instance, instance_location, keyword_location, _context)

lib/json_schemer/draft202012/vocab/format_annotation.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class Format < Keyword
1313
end
1414

1515
def parse
16-
root.format && root.formats.fetch(value, DEFAULT_FORMAT)
16+
root.format && root.formats.fetch(value) { root.meta_schema.formats.fetch(value, DEFAULT_FORMAT) }
1717
end
1818

1919
def validate(instance, instance_location, keyword_location, _context)

lib/json_schemer/draft202012/vocab/format_assertion.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class Format < Keyword
1111
end
1212

1313
def parse
14-
root.format && root.formats.fetch(value, DEFAULT_FORMAT)
14+
root.format && root.formats.fetch(value) { root.meta_schema.formats.fetch(value, DEFAULT_FORMAT) }
1515
end
1616

1717
def validate(instance, instance_location, keyword_location, _context)

lib/json_schemer/keyword.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ class Keyword
55

66
attr_reader :value, :parent, :root, :parsed
77

8-
def initialize(value, parent, keyword)
8+
def initialize(value, parent, keyword, schema = parent)
99
@value = value
1010
@parent = parent
1111
@root = parent.root
1212
@keyword = keyword
13-
@schema = parent
13+
@schema = schema
1414
@parsed = parse
1515
end
1616

@@ -33,8 +33,8 @@ def parse
3333
end
3434

3535
def subschema(value, keyword = nil, **options)
36-
options[:base_uri] ||= parent.base_uri
37-
options[:meta_schema] ||= parent.meta_schema
36+
options[:base_uri] ||= schema.base_uri
37+
options[:meta_schema] ||= schema.meta_schema
3838
Schema.new(value, self, root, keyword, **options)
3939
end
4040
end

lib/json_schemer/openapi.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
module JSONSchemer
3+
class OpenAPI
4+
def initialize(document, **options)
5+
@document = document
6+
7+
version = document['openapi']
8+
@document_schema ||= case version
9+
when /\A3\.1\.\d+\z/
10+
JSONSchemer.openapi31_document
11+
else
12+
raise UnsupportedOpenAPIVersion, version
13+
end
14+
15+
json_schema_dialect = document.fetch('jsonSchemaDialect') { OpenAPI31::BASE_URI.to_s }
16+
meta_schema = META_SCHEMAS_BY_BASE_URI_STR[json_schema_dialect] || raise(UnsupportedMetaSchema, json_schema_dialect)
17+
18+
@schema = JSONSchemer.schema(@document, :meta_schema => meta_schema, **options)
19+
end
20+
21+
def valid?
22+
@document_schema.valid?(@document)
23+
end
24+
25+
def validate(**options)
26+
@document_schema.validate(@document, **options)
27+
end
28+
29+
def ref(value)
30+
@schema.resolve_ref(URI.join(@schema.base_uri, value))
31+
end
32+
33+
def schema(name)
34+
ref("#/components/schemas/#{name}")
35+
end
36+
end
37+
end

0 commit comments

Comments
 (0)