diff --git a/CHANGELOG.md b/CHANGELOG.md index a001c9e..90b236f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6590e92..6f904f1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/shale/mapping/descriptor/dict.rb b/lib/shale/mapping/descriptor/dict.rb index 0008037..b6f46f9 100644 --- a/lib/shale/mapping/descriptor/dict.rb +++ b/lib/shale/mapping/descriptor/dict.rb @@ -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 @@ -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] diff --git a/lib/shale/mapping/dict.rb b/lib/shale/mapping/dict.rb index 091af7f..929455d 100644 --- a/lib/shale/mapping/dict.rb +++ b/lib/shale/mapping/dict.rb @@ -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 diff --git a/lib/shale/mapping/dict_base.rb b/lib/shale/mapping/dict_base.rb index 11f065f..72ea800 100644 --- a/lib/shale/mapping/dict_base.rb +++ b/lib/shale/mapping/dict_base.rb @@ -16,6 +16,13 @@ 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 @@ -23,6 +30,7 @@ class DictBase # @api private def initialize(render_nil_default: false) @keys = {} + @root = {} @finalized = false @render_nil_default = render_nil_default end @@ -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( @@ -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 diff --git a/lib/shale/schema/json_generator.rb b/lib/shale/schema/json_generator.rb index 4810082..79579c2 100644 --- a/lib/shale/schema/json_generator.rb +++ b/lib/shale/schema/json_generator.rb @@ -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 diff --git a/lib/shale/schema/json_generator/base.rb b/lib/shale/schema/json_generator/base.rb index 6e4f5bc..fa0f93c 100644 --- a/lib/shale/schema/json_generator/base.rb +++ b/lib/shale/schema/json_generator/base.rb @@ -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 diff --git a/lib/shale/schema/json_generator/collection.rb b/lib/shale/schema/json_generator/collection.rb index c9526b7..6fb0891 100644 --- a/lib/shale/schema/json_generator/collection.rb +++ b/lib/shale/schema/json_generator/collection.rb @@ -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 @@ -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 diff --git a/lib/shale/schema/json_generator/float.rb b/lib/shale/schema/json_generator/float.rb index fc10158..a258a38 100644 --- a/lib/shale/schema/json_generator/float.rb +++ b/lib/shale/schema/json_generator/float.rb @@ -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 diff --git a/lib/shale/schema/json_generator/integer.rb b/lib/shale/schema/json_generator/integer.rb index cb149df..41cff17 100644 --- a/lib/shale/schema/json_generator/integer.rb +++ b/lib/shale/schema/json_generator/integer.rb @@ -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 diff --git a/lib/shale/schema/json_generator/object.rb b/lib/shale/schema/json_generator/object.rb index 31bd3e2..6ac320a 100644 --- a/lib/shale/schema/json_generator/object.rb +++ b/lib/shale/schema/json_generator/object.rb @@ -16,10 +16,12 @@ class Object < Base # Array # ] properties + # @param [Hash] root # # @api private - def initialize(name, properties) + def initialize(name, properties, root) super(name) + @root = root @properties = properties end @@ -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 diff --git a/lib/shale/schema/json_generator/string.rb b/lib/shale/schema/json_generator/string.rb index 93cc666..1b30203 100644 --- a/lib/shale/schema/json_generator/string.rb +++ b/lib/shale/schema/json_generator/string.rb @@ -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 diff --git a/spec/shale/mapper_spec.rb b/spec/shale/mapper_spec.rb index f301f87..8bad2c4 100644 --- a/spec/shale/mapper_spec.rb +++ b/spec/shale/mapper_spec.rb @@ -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' @@ -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 diff --git a/spec/shale/mapping/descriptor/dict_spec.rb b/spec/shale/mapping/descriptor/dict_spec.rb index e68beb1..2521b60 100644 --- a/spec/shale/mapping/descriptor/dict_spec.rb +++ b/spec/shale/mapping/descriptor/dict_spec.rb @@ -33,6 +33,36 @@ end end + describe 'schema' do + context 'when schema is set' do + it 'returns schema' do + obj = described_class.new( + name: 'foo', + attribute: nil, + receiver: nil, + methods: nil, + group: nil, + render_nil: false, + schema: :bar + ) + expect(obj.schema).to eq(:bar) + end + end + + context 'when schema is not set' do + it 'returns nil' do + obj = described_class.new( + name: 'foo', + attribute: nil, + receiver: nil, + methods: nil, + group: nil, + render_nil: false + ) + expect(obj.schema).to eq(nil) + end + end + end describe '#attribute' do context 'when attribute is set' do it 'returns attribute' do diff --git a/spec/shale/schema/json_generator/base_spec.rb b/spec/shale/schema/json_generator/base_spec.rb index 9092c7d..1b5631e 100644 --- a/spec/shale/schema/json_generator/base_spec.rb +++ b/spec/shale/schema/json_generator/base_spec.rb @@ -113,6 +113,15 @@ def as_type expect(type.as_json).to eq({ 'foo' => 'test-type', 'default' => 'foo' }) end end + + context 'when schema has required set to true' do + it 'returns JSON Schema fragment as Hash' do + schema = { required: true } + type = ShaleSchemaJSONGeneratorBaseTesting::TypeNotNullable.new('foo', default: 'foo', schema: schema) + + expect(type.as_json).to eq({ 'foo' => 'test-type', 'default' => 'foo' }) + end + end end end end diff --git a/spec/shale/schema/json_generator/collection_spec.rb b/spec/shale/schema/json_generator/collection_spec.rb index e329e7c..edfc68b 100644 --- a/spec/shale/schema/json_generator/collection_spec.rb +++ b/spec/shale/schema/json_generator/collection_spec.rb @@ -12,10 +12,52 @@ end end - describe '#as_type' do + describe '#as_json' do it 'returns JSON Schema fragment as Hash' do expected = { 'type' => 'array', 'items' => { 'type' => 'boolean' } } expect(described_class.new(type).as_json).to eq(expected) end + + context 'when schema is passed' do + it 'can include array keywords from JSON schema' do + schema = { + min_items: 2, + max_items: 25, + unique: true, + min_contains: 5, + max_contains: 10, + } + expected = { + 'type' => 'array', + 'items' => { 'type' => 'boolean' }, + 'minItems' => 2, + 'maxItems' => 25, + 'uniqueItems' => true, + 'minContains' => 5, + 'maxContains' => 10, + + } + expect(described_class.new(type, schema: schema).as_json).to eq(expected) + end + + it 'can use a subset of schema keywords' do + schema = { min_items: 4 } + expected = { + 'type' => 'array', + 'items' => { 'type' => 'boolean' }, + 'minItems' => 4, + } + expect(described_class.new(type, schema: schema).as_json).to eq(expected) + end + + it 'will not use keywords for other types' do + schema = { multiple_of: 3 } + expected = { + 'type' => 'array', + 'items' => { 'type' => 'boolean' }, + } + expect(described_class.new(type, schema: schema).as_json).to eq(expected) + end + end end end diff --git a/spec/shale/schema/json_generator/float_spec.rb b/spec/shale/schema/json_generator/float_spec.rb index 3eed745..1b5e542 100644 --- a/spec/shale/schema/json_generator/float_spec.rb +++ b/spec/shale/schema/json_generator/float_spec.rb @@ -8,5 +8,38 @@ expected = { 'type' => 'number' } expect(described_class.new('foo').as_type).to eq(expected) end + + context 'when schema is passed' do + it 'can include numeric keywords from JSON schema' do + schema = { + exclusive_minimum: 0, + exclusive_maximum: 500, + minimum: 0, + maximum: 100, + multiple_of: 4, + } + expected = { + 'type' => 'number', + 'exclusiveMinimum' => 0, + 'exclusiveMaximum' => 500, + 'minimum' => 0, + 'maximum' => 100, + 'multipleOf' => 4, + } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + + it 'can use a subset of schema keywords' do + schema = { minimum: 1 } + expected = { 'type' => 'number', 'minimum' => 1 } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + + it 'will not use keywords for other types' do + schema = { unique_items: true } + expected = { 'type' => 'number' } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + end end end diff --git a/spec/shale/schema/json_generator/integer_spec.rb b/spec/shale/schema/json_generator/integer_spec.rb index 1412af0..eebc5cc 100644 --- a/spec/shale/schema/json_generator/integer_spec.rb +++ b/spec/shale/schema/json_generator/integer_spec.rb @@ -8,5 +8,38 @@ expected = { 'type' => 'integer' } expect(described_class.new('foo').as_type).to eq(expected) end + + context 'when schema is passed' do + it 'can include numeric keywords from JSON schema' do + schema = { + exclusive_minimum: 0, + exclusive_maximum: 500, + minimum: 0, + maximum: 100, + multiple_of: 4, + } + expected = { + 'type' => 'integer', + 'exclusiveMinimum' => 0, + 'exclusiveMaximum' => 500, + 'minimum' => 0, + 'maximum' => 100, + 'multipleOf' => 4, + } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + + it 'can use a subset of schema keywords' do + schema = { minimum: 1 } + expected = { 'type' => 'integer', 'minimum' => 1 } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + + it 'will not use keywords for other types' do + schema = { unique_items: true } + expected = { 'type' => 'integer' } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + end end end diff --git a/spec/shale/schema/json_generator/object_spec.rb b/spec/shale/schema/json_generator/object_spec.rb index fdbbb60..cc8249b 100644 --- a/spec/shale/schema/json_generator/object_spec.rb +++ b/spec/shale/schema/json_generator/object_spec.rb @@ -17,7 +17,52 @@ }, } - expect(described_class.new('foo', types).as_type).to eq(expected) + expect(described_class.new('foo', types, {}).as_type).to eq(expected) + end + + context 'with schema' do + it 'puts properties with `required` on their schema into the "required" array' do + required_schema = { required: true } + + types_with_required = + [ + Shale::Schema::JSONGenerator::Boolean.new('foo', schema: nil), + Shale::Schema::JSONGenerator::Boolean.new('bar', schema: required_schema), + ] + + expected = { + 'type' => 'object', + 'properties' => { + 'foo' => { 'type' => %w[boolean null] }, + 'bar' => { 'type' => 'boolean' }, + }, + 'required' => ['bar'], + } + + expect(described_class.new('foo', types_with_required, {}).as_type).to eq(expected) + end + end + + context 'with root properties' do + it 'puts properties on the root object' do + expected = { + 'type' => 'object', + 'minProperties' => 1, + 'maxProperties' => 5, + 'dependentRequired' => { 'foo' => ['bar'] }, + 'properties' => { + 'bar' => { 'type' => %w[boolean null] }, + }, + } + + root = { + min_properties: 1, + max_properties: 5, + dependent_required: { 'foo' => ['bar'] }, + } + + expect(described_class.new('foo', types, root).as_type).to eq(expected) + end end end end diff --git a/spec/shale/schema/json_generator/schema_spec.rb b/spec/shale/schema/json_generator/schema_spec.rb index c6ff9b1..08bec53 100644 --- a/spec/shale/schema/json_generator/schema_spec.rb +++ b/spec/shale/schema/json_generator/schema_spec.rb @@ -22,8 +22,8 @@ context 'when types are not empty' do it 'returns JSON Schema fragment as Hash' do types = [ - Shale::Schema::JSONGenerator::Object.new('Foo', []), - Shale::Schema::JSONGenerator::Object.new('Bar', []), + Shale::Schema::JSONGenerator::Object.new('Foo', [], {}), + Shale::Schema::JSONGenerator::Object.new('Bar', [], {}), ] schema = described_class.new(types) diff --git a/spec/shale/schema/json_generator/string_spec.rb b/spec/shale/schema/json_generator/string_spec.rb index 8bc21c5..1378f72 100644 --- a/spec/shale/schema/json_generator/string_spec.rb +++ b/spec/shale/schema/json_generator/string_spec.rb @@ -7,5 +7,36 @@ it 'returns JSON Schema fragment as Hash' do expect(described_class.new('foo').as_type).to eq({ 'type' => 'string' }) end + + context 'when schema is passed' do + it 'can include string keywords from JSON schema' do + schema = { + format: 'email', + min_length: 5, + max_length: 10, + pattern: 'foo-bar', + } + expected = { + 'type' => 'string', + 'format' => 'email', + 'minLength' => 5, + 'maxLength' => 10, + 'pattern' => 'foo-bar', + } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + + it 'can use a subset of schema keywords' do + schema = { min_length: 1 } + expected = { 'type' => 'string', 'minLength' => 1 } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + + it 'will not use keywords for other types' do + schema = { unique_items: true } + expected = { 'type' => 'string' } + expect(described_class.new('foo', schema: schema).as_type).to eq(expected) + end + end end end