Skip to content

Commit

Permalink
Add support for JSON Schema validation keywords (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkjohnson authored Oct 15, 2023
1 parent 881ac13 commit c75ca2c
Show file tree
Hide file tree
Showing 21 changed files with 412 additions and 23 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## [1.1.0] - [unreleased]

### Added
- [bkjohnson] Add support for JSON Schema validation keywords (#29)

## [1.0.0] - 2023-07-15

### Added
Expand Down
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,75 @@ end
Shale::Schema::JSONGenerator.register_json_type(MyEmailType, MyEmailJSONType)
```

To add validation keywords to the schema, you can use a custom model and do this:

```ruby
require 'shale/schema'

class PersonMapper < Shale::Mapper
model Person

attribute :first_name, Shale::Type::String
attribute :last_name, Shale::Type::String
attribute :address, Shale::Type::String
attribute :age, Shale::Type::Integer

json do
properties max_properties: 5

map "first_name", to: :first_name, schema: { required: true }
map "last_name", to: :last_name, schema: { required: true }
map "address", to: :age, schema: { max_length: 128 }
map "age", to: :age, schema: { minimum: 1, maximum: 150 }
end
end

Shale::Schema.to_json(
PersonMapper,
pretty: true
)

# =>
#
# {
# "$schema": "https://json-schema.org/draft/2020-12/schema",
# "description": "My description",
# "$ref": "#/$defs/Person",
# "$defs": {
# "Person": {
# "type": "object",
# "maxProperties": 5,
# "properties": {
# "first_name": {
# "type": "string"
# },
# "last_name": {
# "type": "string"
# },
# "age": {
# "type": [
# "integer",
# "null"
# ],
# "minimum": 1,
# "maximum": 150
# },
# "address": {
# "type": [
# "string",
# "null"
# ],
# "maxLength": 128
# }
# },
# "required": ["first_name", "last_name"]
# }
# }
# }
```

Validation keywords are supported for all types, only the global `enum` and `const` types are not supported.

### Compiling JSON Schema into Shale model

:warning: Only **[Draft 2020-12](https://json-schema.org/draft/2020-12/schema)** JSON Schema is supported
Expand Down
11 changes: 10 additions & 1 deletion lib/shale/mapping/descriptor/dict.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ class Dict
# @api private
attr_reader :group

# Return schema hash
#
# @return [Hash]
#
# @api private
attr_reader :schema

# Initialize instance
#
# @param [String] name
Expand All @@ -57,14 +64,16 @@ class Dict
# @param [Hash, nil] methods
# @param [String, nil] group
# @param [true, false] render_nil
# @param [Hash, nil] schema
#
# @api private
def initialize(name:, attribute:, receiver:, methods:, group:, render_nil:)
def initialize(name:, attribute:, receiver:, methods:, group:, render_nil:, schema: nil)
@name = name
@attribute = attribute
@receiver = receiver
@group = group
@render_nil = render_nil
@schema = schema

if methods
@method_from = methods[:from]
Expand Down
7 changes: 4 additions & 3 deletions lib/shale/mapping/dict.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ class Dict < DictBase
# @param [Symbol, nil] receiver
# @param [Hash, nil] using
# @param [true, false, nil] render_nil
# @param [Hash, nil] schema
#
# @raise [IncorrectMappingArgumentsError] when arguments are incorrect
#
# @api private
def map(key, to: nil, receiver: nil, using: nil, render_nil: nil)
super(key, to: to, receiver: receiver, using: using, render_nil: render_nil)
# @api public
def map(key, to: nil, receiver: nil, using: nil, render_nil: nil, schema: nil)
super(key, to: to, receiver: receiver, using: using, render_nil: render_nil, schema: schema)
end

# Set render_nil default
Expand Down
29 changes: 27 additions & 2 deletions lib/shale/mapping/dict_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ class DictBase
# @api private
attr_reader :keys

# Return hash for hash with properties for root Object
#
# @return [Hash]
#
# @api private
attr_reader :root

# Initialize instance
#
# @param [true, false] render_nil_default
#
# @api private
def initialize(render_nil_default: false)
@keys = {}
@root = {}
@finalized = false
@render_nil_default = render_nil_default
end
Expand All @@ -35,11 +43,12 @@ def initialize(render_nil_default: false)
# @param [Hash, nil] using
# @param [String, nil] group
# @param [true, false, nil] render_nil
# @param [Hash, nil] schema
#
# @raise [IncorrectMappingArgumentsError] when arguments are incorrect
#
# @api private
def map(key, to: nil, receiver: nil, using: nil, group: nil, render_nil: nil)
def map(key, to: nil, receiver: nil, using: nil, group: nil, render_nil: nil, schema: nil)
Validator.validate_arguments(key, to, receiver, using)

@keys[key] = Descriptor::Dict.new(
Expand All @@ -48,10 +57,26 @@ def map(key, to: nil, receiver: nil, using: nil, group: nil, render_nil: nil)
receiver: receiver,
methods: using,
group: group,
render_nil: render_nil.nil? ? @render_nil_default : render_nil
render_nil: render_nil.nil? ? @render_nil_default : render_nil,
schema: schema
)
end

# Allow schema properties to be set on the object
#
# @param [Integer] min_properties
# @param [Integer] max_properties
# @param [Hash] dependent_required
#
# @api public
def properties(min_properties: nil, max_properties: nil, dependent_required: nil)
@root = {
min_properties: min_properties,
max_properties: max_properties,
dependent_required: dependent_required,
}
end

# Set the "finalized" instance variable to true
#
# @api private
Expand Down
10 changes: 7 additions & 3 deletions lib/shale/schema/json_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,18 @@ def as_schema(klass, id: nil, title: nil, description: nil)
default = attribute.type.as_json(value)
end

json_type = json_klass.new(mapping.name, default: default)
json_type = json_klass.new(
mapping.name,
default: default,
schema: mapping.schema
)
end

json_type = Collection.new(json_type) if attribute.collection?
json_type = Collection.new(json_type, schema: mapping.schema) if attribute.collection?
properties << json_type
end

objects << Object.new(type.model.name, properties)
objects << Object.new(type.model.name, properties, type.json_mapping.root)
end

Schema.new(objects, id: id, title: title, description: description).as_json
Expand Down
12 changes: 9 additions & 3 deletions lib/shale/schema/json_generator/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@ class Base
# @api private
attr_reader :name

# Return nullable
# Return schema hash
#
# @api private
attr_reader :schema

# Set nullable
#
# @api private
attr_writer :nullable

def initialize(name, default: nil)
def initialize(name, default: nil, schema: nil)
@name = name.gsub('::', '_')
@default = default
@nullable = true
@schema = schema || {}
@nullable = !schema&.[](:required)
end

# Return JSON Schema fragment as Ruby Hash
Expand Down
19 changes: 17 additions & 2 deletions lib/shale/schema/json_generator/collection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ class JSONGenerator
#
# @api private
class Collection
# Return schema hash
#
# @api private
attr_reader :schema

# Initialize Collection object
#
# @param [Shale::Schema::JSONGenerator::Base] type
# @param [Hash] schema
#
# @api private
def initialize(type)
def initialize(type, schema: nil)
@type = type
@schema = schema
end

# Delegate name to wrapped type object
Expand All @@ -31,7 +38,15 @@ def name
#
# @api private
def as_json
{ 'type' => 'array', 'items' => @type.as_type }
schema = @schema || {}

{ 'type' => 'array',
'items' => @type.as_type,
'minItems' => schema[:min_items],
'maxItems' => schema[:max_items],
'uniqueItems' => schema[:unique],
'minContains' => schema[:min_contains],
'maxContains' => schema[:max_contains] }.compact
end
end
end
Expand Down
7 changes: 6 additions & 1 deletion lib/shale/schema/json_generator/float.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ class Float < Base
#
# @api private
def as_type
{ 'type' => 'number' }
{ 'type' => 'number',
'exclusiveMinimum' => schema[:exclusive_minimum],
'exclusiveMaximum' => schema[:exclusive_maximum],
'minimum' => schema[:minimum],
'maximum' => schema[:maximum],
'multipleOf' => schema[:multiple_of] }.compact
end
end
end
Expand Down
7 changes: 6 additions & 1 deletion lib/shale/schema/json_generator/integer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ class Integer < Base
#
# @api private
def as_type
{ 'type' => 'integer' }
{ 'type' => 'integer',
'exclusiveMinimum' => schema[:exclusive_minimum],
'exclusiveMaximum' => schema[:exclusive_maximum],
'minimum' => schema[:minimum],
'maximum' => schema[:maximum],
'multipleOf' => schema[:multiple_of] }.compact
end
end
end
Expand Down
12 changes: 10 additions & 2 deletions lib/shale/schema/json_generator/object.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ class Object < Base
# Array<Shale::Schema::JSONGenerator::Base,
# Shale::Schema::JSONGenerator::Collection>
# ] properties
# @param [Hash] root
#
# @api private
def initialize(name, properties)
def initialize(name, properties, root)
super(name)
@root = root
@properties = properties
end

Expand All @@ -29,10 +31,16 @@ def initialize(name, properties)
#
# @api private
def as_type
required_props = @properties.filter_map { |prop| prop.name if prop&.schema&.[](:required) }

{
'type' => 'object',
'properties' => @properties.to_h { |el| [el.name, el.as_json] },
}
'required' => required_props.empty? ? nil : required_props,
'minProperties' => @root[:min_properties],
'maxProperties' => @root[:max_properties],
'dependentRequired' => @root[:dependent_required],
}.compact
end
end
end
Expand Down
6 changes: 5 additions & 1 deletion lib/shale/schema/json_generator/string.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ class String < Base
#
# @api private
def as_type
{ 'type' => 'string' }
{ 'type' => 'string',
'format' => schema[:format],
'minLength' => schema[:min_length],
'maxLength' => schema[:max_length],
'pattern' => schema[:pattern] }.compact
end
end
end
Expand Down
10 changes: 10 additions & 0 deletions spec/shale/mapper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class JsonMapping < Shale::Mapper
attribute :foo, Shale::Type::String

json do
properties min_properties: 1, max_properties: 4, dependent_required: { 'foo' => ['bar'] }

map 'bar', to: :foo
group from: :method_from, to: :method_to do
map 'baz'
Expand Down Expand Up @@ -663,6 +665,14 @@ class FooBarBaz < described_class
expect(mapping['qux'].method_to).to eq(:method_to)
expect(mapping['qux'].group).to match('group_')
end

it 'allows root properties to be specified' do
root = ShaleMapperTesting::JsonMapping.json_mapping.root

expect(root[:min_properties]).to eq(1)
expect(root[:max_properties]).to eq(4)
expect(root[:dependent_required]).to eq({ 'foo' => ['bar'] })
end
end

describe '.yaml' do
Expand Down
Loading

0 comments on commit c75ca2c

Please sign in to comment.