Skip to content

Commit

Permalink
Add option parse_json to hash and array V3 nodes
Browse files Browse the repository at this point in the history
  • Loading branch information
sudoremo committed Jun 6, 2024
1 parent 1a6bebc commit 51ea255
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 9 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Change log

## 3.0.29 (2024-06-06)

* Add option `parse_json` to `hash` and `array` V3 nodes

Internal reference: `#125211`.

## 3.0.28 (2023-12-20)

* Fix `mailbox` format allowing incorrectly formatted strings
Expand Down
52 changes: 52 additions & 0 deletions README_V3.md
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,14 @@ It consists of one or multiple values, which can be validated using arbitrary no

This is the inverse of option `filter`.

* `parse_json`
Specifies whether JSON is accepted instead of an array. If this is set to
`true` and the given value is a string, Schemacop will attempt to parse the
string as JSON. If the JSON yields a valid array, it will cast the JSON to a
array and validate it using the given schema.

Defaults to `false`.

#### Contains

The `array` node features the *contains* node, which you can use with the DSL
Expand Down Expand Up @@ -855,6 +863,21 @@ end
schema.validate!(['foo', 42, 0]) # => Schemacop::Exceptions::ValidationError: /[0]: Invalid type, got type "String", expected "integer".
```

##### Parsing JSON

By enabling `parse_json`, the given value will be parsed as JSON if it is a
string instead of an array:

```ruby
# This schema will accept any additional properties, but remove them from the result
schema = Schemacop::Schema3.new :array, parse_json: true do
list :integer
end

schema.validate!([1, 2, 3]) # => [1, 2, 3]
schema.validate!('[1, 2, 3]') # => [1, 2, 3]
```

### Hash

Type: `:hash`\
Expand Down Expand Up @@ -892,6 +915,14 @@ It consists of key-value-pairs that can be validated using arbitrary nodes.
If it is set to an enumerable (e.g. `Set` or `Array`), it functions as a
white-list and only the given additional properties are allowed.

* `parse_json`
Specifies whether JSON is accepted instead of a hash. If this is set to
`true` and the given value is a string, Schemacop will attempt to parse the
string as JSON. If the JSON yields a valid hash, it will cast the JSON to a
hash and validate it using the given schema.

Defaults to `false`.

#### Specifying properties

Hash nodes support a block in which you can specify the required hash contents.
Expand Down Expand Up @@ -1115,6 +1146,27 @@ schema.validate!({foo: :bar}) # => {"foo"=>:bar}
schema.validate!({foo: :bar, baz: 42}) # => {"foo"=>:bar}
```

##### Parsing JSON

By enabling `parse_json`, the given value will be parsed as JSON if it is a
string instead of a hash:

```ruby
# This schema will accept any additional properties, but remove them from the result
schema = Schemacop::Schema3.new :hash, parse_json: true do
int! :id
str! :name
end

schema.validate!({
id: 42,
name: 'Jane Doe'
}) # => { id: 42, name: 'Jane Doe' }
schema.validate!('{ "id": 42, name: "Jane Doe" }') # => { "id" => 42, "name" => 'Jane Doe' }
```

Note that the parsed JSON will always result in string hash keys, not symbols.

##### Dependencies

Using the DSL method `dep`, you can specifiy (non-nested) property dependencies:
Expand Down
13 changes: 11 additions & 2 deletions lib/schemacop/v3/array_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class ArrayNode < Node
supports_children

def self.allowed_options
super + ATTRIBUTES + %i[additional_items reject filter]
super + ATTRIBUTES + %i[additional_items reject filter parse_json]
end

def self.dsl_methods
Expand Down Expand Up @@ -73,13 +73,21 @@ def as_json
end

def allowed_types
{ Array => :array }
if options[:parse_json]
{ Array => :array, String => :array }
else
{ Array => :array }
end
end

def _validate(data, result:)
super_data = super
return if super_data.nil?

# Handle JSON
super_data = parse_if_json(super_data, result: result, allowed_types: { Array => :array })
return if super_data.nil?

# Preprocess
super_data = preprocess_array(super_data)

Expand Down Expand Up @@ -142,6 +150,7 @@ def children

def cast(value)
return default unless value
value = parse_if_json(value, allowed_types: { Array => :array })

result = []

Expand Down
13 changes: 11 additions & 2 deletions lib/schemacop/v3/hash_node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class HashNode < Node
attr_reader :properties

def self.allowed_options
super + ATTRIBUTES - %i[dependencies] + %i[additional_properties ignore_obsolete_properties]
super + ATTRIBUTES - %i[dependencies] + %i[additional_properties ignore_obsolete_properties parse_json]
end

def self.dsl_methods
Expand Down Expand Up @@ -78,13 +78,21 @@ def as_json
end

def allowed_types
{ Hash => :object }
if options[:parse_json]
{ Hash => :object, String => :string }
else
{ Hash => :object }
end
end

def _validate(data, result: Result.new)
super_data = super
return if super_data.nil?

# Handle JSON
super_data = parse_if_json(super_data, result: result, allowed_types: { Hash => :object })
return if super_data.nil?

original_data_hash = super_data.dup
data_hash = super_data.with_indifferent_access

Expand Down Expand Up @@ -169,6 +177,7 @@ def children
end

def cast(data)
data = parse_if_json(data, allowed_types: { Hash => :object })
result = {}.with_indifferent_access
data ||= default
return nil if data.nil?
Expand Down
35 changes: 30 additions & 5 deletions lib/schemacop/v3/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,25 @@ def process_json(attrs, json)
return json.as_json
end

def parse_if_json(data, result: nil, allowed_types:)
if data.is_a?(String)
data = JSON.parse(data)

if result
return nil unless validate_type(data, result, allowed_types: allowed_types)
end
end

return data
rescue JSON::ParserError => e
if result
result.error "JSON parse error: #{e.message.inspect}."
end

return nil
end


def type_assertion_method
:is_a?
end
Expand All @@ -213,11 +232,7 @@ def _validate(data, result:)
end

# Validate type #
if allowed_types.any? && allowed_types.keys.none? { |c| data.send(type_assertion_method, c) }
collection = allowed_types.values.map { |t| "\"#{t}\"" }.uniq.sort.join(' or ')
result.error "Invalid type, got type \"#{data.class}\", expected #{collection}."
return nil
end
return nil unless validate_type(data, result)

# Validate enums #
if @enum && !@enum.include?(data)
Expand All @@ -227,6 +242,16 @@ def _validate(data, result:)
return data
end

def validate_type(data, result, allowed_types: self.allowed_types)
if allowed_types.any? && allowed_types.keys.none? { |c| data.send(type_assertion_method, c) }
collection = allowed_types.values.map { |t| "\"#{t}\"" }.uniq.sort.join(' or ')
result.error "Invalid type, got type \"#{data.class}\", expected #{collection}."
return false
end

return true
end

def validate_self; end
end
end
Expand Down
25 changes: 25 additions & 0 deletions test/unit/schemacop/v3/array_node_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,31 @@ def test_contains_multiple_should_fail
end
end
end

def test_parse_json
schema :array, parse_json: true do
list :integer
end
assert_validation([1, 2, 3])
assert_validation('[1,2,3]')
assert_cast('[1,2,3]', [1, 2, 3])

assert_validation('[1,2,"3"]') do
error '/[2]', 'Invalid type, got type "String", expected "integer".'
end

assert_validation('{ "id": 42 }') do
error '/', 'Invalid type, got type "Hash", expected "array".'
end

assert_validation('{42]') do
error '/', %(JSON parse error: "767: unexpected token at '{42]'".)
end

assert_validation('"foo"') do
error '/', 'Invalid type, got type "String", expected "array".'
end
end
end
end
end
35 changes: 35 additions & 0 deletions test/unit/schemacop/v3/hash_node_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,41 @@ def test_basic
assert_json(type: :object, additionalProperties: false)
end

def test_parse_json
schema :hash, parse_json: true do
int! :id
str! :name
end
assert_validation({ id: 42, name: 'Jane Doe' })
assert_validation('{"id":42,"name":"Jane Doe"}')
assert_cast('{"id":42,"name":"Jane Doe"}', { id: 42, name: 'Jane Doe' }.stringify_keys)

assert_validation('{"id":42,"name":42}') do
error '/name', 'Invalid type, got type "Integer", expected "string".'
end

assert_validation('[42]') do
error '/', 'Invalid type, got type "Array", expected "object".'
end

assert_validation('{42]') do
error '/', %(JSON parse error: "767: unexpected token at '{42]'".)
end

assert_validation('"foo"') do
error '/', 'Invalid type, got type "String", expected "object".'
end

schema :hash do
int! :id
end

assert_validation({ id: 42 })
assert_validation('{"id":42}') do
error '/', 'Invalid type, got type "String", expected "object".'
end
end

def test_additional_properties_false_by_default
schema
assert_validation({})
Expand Down

0 comments on commit 51ea255

Please sign in to comment.