Skip to content

Commit

Permalink
OpenAPI 3.0
Browse files Browse the repository at this point in the history
This adds support for OpenAPI 3.0 documents and schemas. There's no
published meta schema or dialect that I could find, so the meta schema
refs into the published document schema instead. Everything is based off
of draft 4.

The keywords additions are `nullable` (implemented in `type`),
`readOnly`, and `writeOnly`. There are also a couple new formats (`byte`
and `binary`).

`readOnly` and `writeOnly` are implemented with a new option called
`access_mode` that triggers errors for invalid access, ie a `writeOnly`
property is present in "read" access mode. It also removes keys from
`required` depending on the specified mode. These features are
implemented in draft 2020-12 because they seemed like they might be
useful elsewhere and they're off by default.

`nullable` just adds "null" to the allowed `type` array. You'd probably
get better errors with an actual `Nullable` keyword class, but it didn't
seem worth it at this point.

Related:

- #55
  • Loading branch information
davishmcclurg committed Jul 29, 2023
1 parent ac13635 commit de9de96
Show file tree
Hide file tree
Showing 13 changed files with 2,028 additions and 33 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# JSONSchemer

JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, 2020-12, and OpenAPI 3.1.
JSON Schema validator. Supports drafts 4, 6, 7, 2019-09, 2020-12, OpenAPI 3.0, and OpenAPI 3.1.

## Installation

Expand Down Expand Up @@ -167,11 +167,16 @@ JSONSchemer.schema(
# output formatting (https://json-schema.org/draft/2020-12/json-schema-core.html#section-12)
# 'classic'/'flag'/'basic'/'detailed'/'verbose'
# default: 'classic'
output_format: 'basic'
output_format: 'basic',

# validate `readOnly`/`writeOnly` keywords (https://spec.openapis.org/oas/v3.0.3#fixed-fields-19)
# 'read'/'write'/nil
# default: nil
access_mode: 'read'
)
```

## OpenAPI 3.1
## OpenAPI

```ruby
document = JSONSchemer.openapi({
Expand Down
2 changes: 1 addition & 1 deletion json_schemer.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
spec.authors = ["David Harsha"]
spec.email = ["davishmcclurg@gmail.com"]

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

Expand Down
41 changes: 39 additions & 2 deletions lib/json_schemer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
require 'json_schemer/draft202012/vocab/format_annotation'
require 'json_schemer/draft202012/vocab/format_assertion'
require 'json_schemer/draft202012/vocab/content'
require 'json_schemer/draft202012/vocab/meta_data'
require 'json_schemer/draft202012/vocab'
require 'json_schemer/draft201909/meta'
require 'json_schemer/draft201909/vocab/core'
Expand All @@ -52,6 +53,10 @@
require 'json_schemer/openapi31/vocab/base'
require 'json_schemer/openapi31/vocab'
require 'json_schemer/openapi31/document'
require 'json_schemer/openapi30/document'
require 'json_schemer/openapi30/meta'
require 'json_schemer/openapi30/vocab/base'
require 'json_schemer/openapi30/vocab'
require 'json_schemer/openapi'
require 'json_schemer/schema'

Expand Down Expand Up @@ -91,7 +96,8 @@ class InvalidEcmaRegexp < StandardError; end
'json-schemer://draft6' => Draft6::Vocab::ALL,
'json-schemer://draft4' => Draft4::Vocab::ALL,

'https://spec.openapis.org/oas/3.1/vocab/base' => OpenAPI31::Vocab::BASE
'https://spec.openapis.org/oas/3.1/vocab/base' => OpenAPI31::Vocab::BASE,
'json-schemer://openapi30' => OpenAPI30::Vocab::BASE
}
VOCABULARY_ORDER = VOCABULARIES.transform_values.with_index { |_vocabulary, index| index }

Expand Down Expand Up @@ -197,6 +203,28 @@ def openapi31
)
end

def openapi30
@openapi30 ||= Schema.new(
OpenAPI30::SCHEMA,
:vocabulary => {
'json-schemer://draft4' => true,
'json-schemer://openapi30' => true
},
:base_uri => OpenAPI30::BASE_URI,
:ref_resolver => OpenAPI30::Meta::SCHEMAS.to_proc,
:regexp_resolver => 'ecma',
:formats => {
'int32' => proc { |instance, _value| instance.is_a?(Integer) && instance.bit_length <= 32 },
'int64' => proc { |instance, _value| instance.is_a?(Integer) && instance.bit_length <= 64 },
'float' => proc { |instance, _value| instance.is_a?(Float) },
'double' => proc { |instance, _value| instance.is_a?(Float) },
'byte' => proc { |instance, _value| Format.decode_content_encoding(instance, 'base64').first },
'binary' => proc { |instance, _value| instance.is_a?(String) && instance.encoding == Encoding::ASCII_8BIT },
'password' => proc { |_instance, _value| true }
}
)
end

def openapi31_document
@openapi31_document ||= Schema.new(
OpenAPI31::Document::SCHEMA_BASE,
Expand All @@ -205,6 +233,14 @@ def openapi31_document
)
end

def openapi30_document
@openapi30_document ||= Schema.new(
OpenAPI30::Document::SCHEMA,
:ref_resolver => OpenAPI30::Document::SCHEMAS.to_proc,
:regexp_resolver => 'ecma'
)
end

def openapi(document, **options)
OpenAPI.new(document, **options)
end
Expand All @@ -218,7 +254,8 @@ def openapi(document, **options)
Draft4::BASE_URI.to_s => method(:draft4),
# version-less $schema deprecated after Draft 4
'http://json-schema.org/schema#' => method(:draft4),
OpenAPI31::BASE_URI.to_s => method(:openapi31)
OpenAPI31::BASE_URI.to_s => method(:openapi31),
OpenAPI30::BASE_URI.to_s => method(:openapi30)
}.freeze

META_SCHEMAS_BY_BASE_URI_STR = Hash.new do |hash, base_uri_str|
Expand Down
4 changes: 2 additions & 2 deletions lib/json_schemer/draft202012/vocab.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ module Vocab
# 'description' => MetaData::Description,
# 'default' => MetaData::Default,
# 'deprecated' => MetaData::Deprecated,
# 'readOnly' => MetaData::ReadOnly,
# 'writeOnly' => MetaData::WriteOnly,
'readOnly' => MetaData::ReadOnly,
'writeOnly' => MetaData::WriteOnly,
# 'examples' => MetaData::Examples
}
end
Expand Down
30 changes: 30 additions & 0 deletions lib/json_schemer/draft202012/vocab/meta_data.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true
module JSONSchemer
module Draft202012
module Vocab
module MetaData
class ReadOnly < Keyword
def error(formatted_instance_location:, **)
"instance at #{formatted_instance_location} is `readOnly`"
end

def validate(instance, instance_location, keyword_location, context)
valid = parsed != true || !context.access_mode || context.access_mode == 'read'
result(instance, instance_location, keyword_location, valid, :annotation => value)
end
end

class WriteOnly < Keyword
def error(formatted_instance_location:, **)
"instance at #{formatted_instance_location} is `writeOnly`"
end

def validate(instance, instance_location, keyword_location, context)
valid = parsed != true || !context.access_mode || context.access_mode == 'write'
result(instance, instance_location, keyword_location, valid, :annotation => value)
end
end
end
end
end
end
23 changes: 18 additions & 5 deletions lib/json_schemer/draft202012/vocab/validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ def error(formatted_instance_location:, keyword_value:, **)
end

def validate(instance, instance_location, keyword_location, _context)
case value
case parsed
when String
result(instance, instance_location, keyword_location, valid_type(value, instance), :type => value)
result(instance, instance_location, keyword_location, valid_type(parsed, instance), :type => parsed)
when Array
result(instance, instance_location, keyword_location, value.any? { |type| valid_type(type, instance) })
result(instance, instance_location, keyword_location, parsed.any? { |type| valid_type(type, instance) })
end
end

Expand Down Expand Up @@ -241,9 +241,22 @@ def error(formatted_instance_location:, details:, **)
"hash at #{formatted_instance_location} is missing required keys: #{details.fetch('missing_keys')}"
end

def validate(instance, instance_location, keyword_location, _context)
def validate(instance, instance_location, keyword_location, context)
return result(instance, instance_location, keyword_location, true) unless instance.is_a?(Hash)
missing_keys = value - instance.keys

required_keys = value

if context.access_mode && schema.parsed.key?('properties')
inapplicable_access_mode_keys = []
schema.parsed.fetch('properties').parsed.each do |property, subschema|
read_only, write_only = subschema.parsed.values_at('readOnly', 'writeOnly')
inapplicable_access_mode_keys << property if context.access_mode == 'write' && read_only&.parsed == true
inapplicable_access_mode_keys << property if context.access_mode == 'read' && write_only&.parsed == true
end
required_keys -= inapplicable_access_mode_keys
end

missing_keys = required_keys - instance.keys
result(instance, instance_location, keyword_location, missing_keys.none?, :details => { 'missing_keys' => missing_keys })
end
end
Expand Down
9 changes: 6 additions & 3 deletions lib/json_schemer/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ def initialize(document, **options)
@document = document

version = document['openapi']
@document_schema ||= case version
case version
when /\A3\.1\.\d+\z/
JSONSchemer.openapi31_document
@document_schema = JSONSchemer.openapi31_document
json_schema_dialect = document.fetch('jsonSchemaDialect') { OpenAPI31::BASE_URI.to_s }
when /\A3\.0\.\d+\z/
@document_schema = JSONSchemer.openapi30_document
json_schema_dialect = OpenAPI30::BASE_URI.to_s
else
raise UnsupportedOpenAPIVersion, version
end

json_schema_dialect = document.fetch('jsonSchemaDialect') { OpenAPI31::BASE_URI.to_s }
meta_schema = META_SCHEMAS_BY_BASE_URI_STR[json_schema_dialect] || raise(UnsupportedMetaSchema, json_schema_dialect)

@schema = JSONSchemer.schema(@document, :meta_schema => meta_schema, **options)
Expand Down

0 comments on commit de9de96

Please sign in to comment.