json_schema 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +36 -0
- data/lib/json_pointer.rb +7 -0
- data/lib/json_pointer/evaluator.rb +75 -0
- data/lib/json_reference.rb +39 -0
- data/lib/json_schema.rb +27 -0
- data/lib/json_schema/parser.rb +296 -0
- data/lib/json_schema/reference_expander.rb +156 -0
- data/lib/json_schema/schema.rb +155 -0
- data/lib/json_schema/schema_error.rb +25 -0
- data/lib/json_schema/validator.rb +405 -0
- data/test/data_scaffold.rb +238 -0
- data/test/json_pointer/evaluator_test.rb +60 -0
- data/test/json_schema/parser_test.rb +230 -0
- data/test/json_schema/reference_expander_test.rb +149 -0
- data/test/json_schema/validator_test.rb +606 -0
- data/test/json_schema_test.rb +67 -0
- data/test/test_helper.rb +4 -0
- metadata +64 -0
data/README.md
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# json_schema
|
2
|
+
|
3
|
+
A JSON Schema V4 and Hyperschema V4 parser and validator.
|
4
|
+
|
5
|
+
``` ruby
|
6
|
+
require "json"
|
7
|
+
require "json_schema"
|
8
|
+
|
9
|
+
# parse the schema
|
10
|
+
schema_data = JSON.parse(File.read("schema.json"))
|
11
|
+
schema = JsonSchema.parse!(schema_data)
|
12
|
+
|
13
|
+
# validate some data
|
14
|
+
data = JSON.parse(File.read("data.json"))
|
15
|
+
schema.validate!(data)
|
16
|
+
|
17
|
+
# iterate through hyperschema links
|
18
|
+
schema.links.each do |link|
|
19
|
+
puts "#{link.method} #{link.href}"
|
20
|
+
end
|
21
|
+
```
|
22
|
+
|
23
|
+
## Development
|
24
|
+
|
25
|
+
Run the test suite with:
|
26
|
+
|
27
|
+
```
|
28
|
+
rake
|
29
|
+
```
|
30
|
+
|
31
|
+
Or run specific suites or tests with:
|
32
|
+
|
33
|
+
```
|
34
|
+
ruby -Ilib -Itest test/json_schema/validator_test.rb
|
35
|
+
ruby -Ilib -Itest test/json_schema/validator_test.rb -n /anyOf/
|
36
|
+
```
|
data/lib/json_pointer.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
module JsonPointer
|
2
|
+
class Evaluator
|
3
|
+
def initialize(data)
|
4
|
+
@data = data
|
5
|
+
end
|
6
|
+
|
7
|
+
def evaluate(original_path)
|
8
|
+
path = original_path
|
9
|
+
|
10
|
+
# the leading # can either be included or not
|
11
|
+
path = path[1..-1] if path[0] == "#"
|
12
|
+
|
13
|
+
# special case on "" or presumably "#"
|
14
|
+
if path.empty?
|
15
|
+
return @data
|
16
|
+
end
|
17
|
+
|
18
|
+
if path[0] != "/"
|
19
|
+
raise %{Path must begin with a leading "/": #{original_path}.}
|
20
|
+
end
|
21
|
+
|
22
|
+
path_parts = split(path)
|
23
|
+
evaluate_segment(@data, path_parts)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def evaluate_segment(data, path_parts)
|
29
|
+
if path_parts.empty?
|
30
|
+
data
|
31
|
+
elsif data == nil
|
32
|
+
# spec doesn't define how to handle this, so we'll return `nil`
|
33
|
+
nil
|
34
|
+
else
|
35
|
+
key = transform_key(path_parts.shift)
|
36
|
+
if data.is_a?(Array)
|
37
|
+
unless key =~ /^\d+$/
|
38
|
+
raise %{Key operating on an array must be a digit or "-": #{key}.}
|
39
|
+
end
|
40
|
+
evaluate_segment(data[key.to_i], path_parts)
|
41
|
+
else
|
42
|
+
evaluate_segment(data[key], path_parts)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# custom split method to account for blank segments
|
48
|
+
def split(path)
|
49
|
+
parts = []
|
50
|
+
last_index = 0
|
51
|
+
while index = path.index("/", last_index)
|
52
|
+
if index == last_index
|
53
|
+
parts << ""
|
54
|
+
else
|
55
|
+
parts << path[last_index...index]
|
56
|
+
end
|
57
|
+
last_index = index + 1
|
58
|
+
end
|
59
|
+
# and also get that last segment
|
60
|
+
parts << path[last_index..-1]
|
61
|
+
# it should begin with a blank segment from the leading "/"; kill that
|
62
|
+
parts.shift
|
63
|
+
parts
|
64
|
+
end
|
65
|
+
|
66
|
+
def transform_key(key)
|
67
|
+
# ~ has special meaning to JSON pointer to allow keys containing "/", so
|
68
|
+
# perform some transformations first as defined by the spec
|
69
|
+
# first as defined by the spec
|
70
|
+
key = key.gsub('~1', '/')
|
71
|
+
key = key.gsub('~0', '~')
|
72
|
+
key
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require "json_pointer"
|
2
|
+
require "uri"
|
3
|
+
|
4
|
+
module JsonReference
|
5
|
+
class Reference
|
6
|
+
attr_accessor :pointer
|
7
|
+
attr_accessor :uri
|
8
|
+
|
9
|
+
def initialize(ref)
|
10
|
+
# given a simple fragment without '#', resolve as a JSON Pointer only as
|
11
|
+
# per spec
|
12
|
+
if ref.include?("#")
|
13
|
+
uri, @pointer = ref.split('#')
|
14
|
+
if uri && !uri.empty?
|
15
|
+
@uri = URI.parse(uri)
|
16
|
+
end
|
17
|
+
else
|
18
|
+
@pointer = ref
|
19
|
+
end
|
20
|
+
|
21
|
+
# normalize pointers by prepending "#"
|
22
|
+
@pointer = "#" + @pointer
|
23
|
+
end
|
24
|
+
|
25
|
+
# Given the document addressed by #uri, resolves the JSON Pointer part of
|
26
|
+
# the reference.
|
27
|
+
def resolve_pointer(data)
|
28
|
+
JsonPointer.evaluate(data, @pointer)
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_s
|
32
|
+
if @uri
|
33
|
+
"#{@uri.to_s}#{@pointer}"
|
34
|
+
else
|
35
|
+
@pointer
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/json_schema.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require_relative "json_schema/parser"
|
2
|
+
require_relative "json_schema/reference_expander"
|
3
|
+
require_relative "json_schema/schema"
|
4
|
+
require_relative "json_schema/schema_error"
|
5
|
+
require_relative "json_schema/validator"
|
6
|
+
|
7
|
+
module JsonSchema
|
8
|
+
def self.parse(data)
|
9
|
+
parser = Parser.new
|
10
|
+
if schema = parser.parse(data)
|
11
|
+
valid, errors = schema.expand_references
|
12
|
+
if valid
|
13
|
+
[schema, nil]
|
14
|
+
else
|
15
|
+
[nil, errors]
|
16
|
+
end
|
17
|
+
else
|
18
|
+
[nil, parser.errors]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.parse!(data)
|
23
|
+
schema = Parser.new.parse!(data)
|
24
|
+
schema.expand_references!
|
25
|
+
schema
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,296 @@
|
|
1
|
+
require "json_reference"
|
2
|
+
|
3
|
+
module JsonSchema
|
4
|
+
class Parser
|
5
|
+
ALLOWED_TYPES = %w{any array boolean integer number null object string}
|
6
|
+
BOOLEAN = [FalseClass, TrueClass]
|
7
|
+
FRIENDLY_TYPES = {
|
8
|
+
Array => "array",
|
9
|
+
FalseClass => "boolean",
|
10
|
+
Float => "number",
|
11
|
+
Hash => "object",
|
12
|
+
Integer => "integer",
|
13
|
+
NilClass => "null",
|
14
|
+
String => "string",
|
15
|
+
TrueClass => "boolean",
|
16
|
+
}
|
17
|
+
|
18
|
+
attr_accessor :errors
|
19
|
+
|
20
|
+
# Basic parsing of a schema. May return a malformed schema! (Use `#parse!`
|
21
|
+
# to raise errors instead).
|
22
|
+
def parse(data, parent = nil)
|
23
|
+
# while #parse_data is recursed into for many schemas over the same
|
24
|
+
# object, the @errors array is an instance-wide accumulator
|
25
|
+
@errors = []
|
26
|
+
|
27
|
+
schema = parse_data(data, parent)
|
28
|
+
if @errors.count == 0
|
29
|
+
schema
|
30
|
+
else
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def parse!(data, parent = nil)
|
36
|
+
schema = parse(data, parent)
|
37
|
+
if !schema
|
38
|
+
raise SchemaError.aggregate(@errors)
|
39
|
+
end
|
40
|
+
schema
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def build_uri(id, parent_uri)
|
46
|
+
# kill any trailing slashes
|
47
|
+
if id
|
48
|
+
id = id.chomp("/")
|
49
|
+
end
|
50
|
+
|
51
|
+
# if id is missing, it's defined as its parent schema's URI
|
52
|
+
if id.nil?
|
53
|
+
parent_uri
|
54
|
+
# if id is defined as absolute, the schema's URI stays absolute
|
55
|
+
elsif id[0] == "/"
|
56
|
+
id
|
57
|
+
# otherwise build it according to the parent's URI
|
58
|
+
else
|
59
|
+
# make sure we don't end up with duplicate slashes
|
60
|
+
parent_uri = parent_uri.chomp("/")
|
61
|
+
parent_uri + "/" + id
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def parse_all_of(schema)
|
66
|
+
if schema.all_of && schema.all_of.is_a?(Array)
|
67
|
+
schema.all_of = schema.all_of.map { |s| parse_data(s, schema) }
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def parse_any_of(schema)
|
72
|
+
if schema.any_of && schema.any_of.is_a?(Array)
|
73
|
+
schema.any_of = schema.any_of.map { |s| parse_data(s, schema) }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def parse_one_of(schema)
|
78
|
+
if schema.one_of && schema.one_of.is_a?(Array)
|
79
|
+
schema.one_of = schema.one_of.map { |s| parse_data(s, schema) }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def parse_data(data, parent = nil)
|
84
|
+
schema = Schema.new
|
85
|
+
|
86
|
+
if !data.is_a?(Hash)
|
87
|
+
# it would be nice to make this message more specific/nicer (at best it
|
88
|
+
# points to the wrong schema)
|
89
|
+
message = %{Expected schema; value was: #{data.inspect}.}
|
90
|
+
@errors << SchemaError.new(parent, message)
|
91
|
+
elsif ref = data["$ref"]
|
92
|
+
schema.reference = JsonReference::Reference.new(ref)
|
93
|
+
else
|
94
|
+
schema = parse_schema(data, parent)
|
95
|
+
end
|
96
|
+
|
97
|
+
schema.parent = parent
|
98
|
+
schema
|
99
|
+
end
|
100
|
+
|
101
|
+
def parse_definitions(schema)
|
102
|
+
if schema.definitions && schema.definitions.is_a?(Hash)
|
103
|
+
# leave the original data reference intact
|
104
|
+
schema.definitions = schema.definitions.dup
|
105
|
+
schema.definitions.each do |key, definition|
|
106
|
+
subschema = parse_data(definition, schema)
|
107
|
+
schema.definitions[key] = subschema
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def parse_dependencies(schema)
|
113
|
+
if schema.dependencies && schema.dependencies.is_a?(Hash)
|
114
|
+
# leave the original data reference intact
|
115
|
+
schema.dependencies = schema.dependencies.dup
|
116
|
+
schema.dependencies.each do |k, s|
|
117
|
+
# may be Array, String (simple dependencies), or Hash (schema
|
118
|
+
# dependency)
|
119
|
+
if s.is_a?(Hash)
|
120
|
+
schema.dependencies[k] = parse_data(s, schema)
|
121
|
+
elsif s.is_a?(String)
|
122
|
+
# just normalize all simple dependencies to arrays
|
123
|
+
schema.dependencies[k] = [s]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def parse_items(schema)
|
130
|
+
if schema.items
|
131
|
+
# tuple validation: an array of schemas
|
132
|
+
if schema.items.is_a?(Array)
|
133
|
+
schema.items = schema.items.map { |s| parse_data(s, schema) }
|
134
|
+
# list validation: a single schema
|
135
|
+
else
|
136
|
+
schema.items = parse_data(schema.items, schema)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def parse_links(schema)
|
142
|
+
if schema.links
|
143
|
+
schema.links = schema.links.map { |l|
|
144
|
+
link = Schema::Link.new
|
145
|
+
link.parent = schema
|
146
|
+
|
147
|
+
link.description = l["description"]
|
148
|
+
link.href = l["href"]
|
149
|
+
link.method = l["method"] ? l["method"].downcase.to_sym : nil
|
150
|
+
link.rel = l["rel"]
|
151
|
+
link.title = l["title"]
|
152
|
+
|
153
|
+
if l["schema"]
|
154
|
+
link.schema = parse_data(l["schema"], schema)
|
155
|
+
end
|
156
|
+
|
157
|
+
link
|
158
|
+
}
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def parse_media(schema)
|
163
|
+
if data = schema.media
|
164
|
+
schema.media = Schema::Media.new
|
165
|
+
schema.media.binary_encoding = data["binaryEncoding"]
|
166
|
+
schema.media.type = data["type"]
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def parse_not(schema)
|
171
|
+
if schema.not && schema.not.is_a?(Hash)
|
172
|
+
schema.not = parse_data(schema.not, schema)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def parse_pattern_properties(schema)
|
177
|
+
if schema.pattern_properties && schema.pattern_properties.is_a?(Hash)
|
178
|
+
# leave the original data reference intact
|
179
|
+
properties = schema.pattern_properties.dup
|
180
|
+
properties = properties.map do |k, s|
|
181
|
+
[Regexp.new(k), parse_data(s, schema)]
|
182
|
+
end
|
183
|
+
schema.pattern_properties = Hash[*properties.flatten]
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def parse_properties(schema)
|
188
|
+
# leave the original data reference intact
|
189
|
+
schema.properties = schema.properties.dup
|
190
|
+
if schema.properties && schema.properties.is_a?(Hash)
|
191
|
+
schema.properties.each do |key, definition|
|
192
|
+
subschema = parse_data(definition, schema)
|
193
|
+
schema.properties[key] = subschema
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def parse_schema(data, parent = nil)
|
199
|
+
schema = Schema.new
|
200
|
+
|
201
|
+
schema.data = data
|
202
|
+
schema.id = validate_type(schema, [String], "id")
|
203
|
+
|
204
|
+
# build URI early so we can reference it in errors
|
205
|
+
schema.uri = parent ? build_uri(schema.id, parent.uri) : "/"
|
206
|
+
|
207
|
+
schema.title = validate_type(schema, [String], "title")
|
208
|
+
schema.description = validate_type(schema, [String], "description")
|
209
|
+
schema.default = schema.data["default"]
|
210
|
+
|
211
|
+
# validation: any
|
212
|
+
schema.all_of = validate_type(schema, [Array], "allOf") || []
|
213
|
+
schema.any_of = validate_type(schema, [Array], "anyOf") || []
|
214
|
+
schema.definitions = validate_type(schema, [Hash], "definitions") || {}
|
215
|
+
schema.enum = validate_type(schema, [Array], "enum")
|
216
|
+
schema.one_of = validate_type(schema, [Array], "oneOf") || []
|
217
|
+
schema.not = validate_type(schema, [Hash], "not")
|
218
|
+
schema.type = validate_type(schema, [Array, String], "type")
|
219
|
+
schema.type = [schema.type] if schema.type.is_a?(String)
|
220
|
+
validate_known_type!(schema)
|
221
|
+
|
222
|
+
# validation: array
|
223
|
+
schema.additional_items = validate_type(schema, BOOLEAN, "additionalItems")
|
224
|
+
schema.items = validate_type(schema, [Array, Hash], "items")
|
225
|
+
schema.max_items = validate_type(schema, [Integer], "maxItems")
|
226
|
+
schema.min_items = validate_type(schema, [Integer], "minItems")
|
227
|
+
schema.unique_items = validate_type(schema, BOOLEAN, "uniqueItems")
|
228
|
+
|
229
|
+
# validation: number/integer
|
230
|
+
schema.max = validate_type(schema, [Float, Integer], "maximum")
|
231
|
+
schema.max_exclusive = validate_type(schema, BOOLEAN, "exclusiveMaximum")
|
232
|
+
schema.min = validate_type(schema, [Float, Integer], "minimum")
|
233
|
+
schema.min_exclusive = validate_type(schema, BOOLEAN, "exclusiveMinimum")
|
234
|
+
schema.multiple_of = validate_type(schema, [Float, Integer], "multipleOf")
|
235
|
+
|
236
|
+
# validation: object
|
237
|
+
schema.additional_properties =
|
238
|
+
validate_type(schema, BOOLEAN, "additionalProperties")
|
239
|
+
schema.dependencies = validate_type(schema, [Hash], "dependencies") || {}
|
240
|
+
schema.max_properties = validate_type(schema, [Integer], "maxProperties")
|
241
|
+
schema.min_properties = validate_type(schema, [Integer], "minProperties")
|
242
|
+
schema.pattern_properties = validate_type(schema, [Hash], "patternProperties") || {}
|
243
|
+
schema.properties = validate_type(schema, [Hash], "properties") || {}
|
244
|
+
schema.required = validate_type(schema, [Array], "required")
|
245
|
+
|
246
|
+
# validation: string
|
247
|
+
schema.format = validate_type(schema, [String], "format")
|
248
|
+
schema.max_length = validate_type(schema, [Integer], "maxLength")
|
249
|
+
schema.min_length = validate_type(schema, [Integer], "minLength")
|
250
|
+
schema.pattern = validate_type(schema, [String], "pattern")
|
251
|
+
schema.pattern = Regexp.new(schema.pattern) if schema.pattern
|
252
|
+
|
253
|
+
# hyperschema
|
254
|
+
schema.links = validate_type(schema, [Array], "links")
|
255
|
+
schema.media = validate_type(schema, [Hash], "media")
|
256
|
+
schema.path_start = validate_type(schema, [String], "pathStart")
|
257
|
+
schema.read_only = validate_type(schema, BOOLEAN, "readOnly")
|
258
|
+
|
259
|
+
parse_all_of(schema)
|
260
|
+
parse_any_of(schema)
|
261
|
+
parse_one_of(schema)
|
262
|
+
parse_definitions(schema)
|
263
|
+
parse_dependencies(schema)
|
264
|
+
parse_items(schema)
|
265
|
+
parse_links(schema)
|
266
|
+
parse_media(schema)
|
267
|
+
parse_not(schema)
|
268
|
+
parse_pattern_properties(schema)
|
269
|
+
parse_properties(schema)
|
270
|
+
|
271
|
+
schema
|
272
|
+
end
|
273
|
+
|
274
|
+
def validate_known_type!(schema)
|
275
|
+
if schema.type
|
276
|
+
if !(bad_types = schema.type - ALLOWED_TYPES).empty?
|
277
|
+
message = %{Unknown types: #{bad_types.sort.join(", ")}.}
|
278
|
+
@errors << SchemaError.new(schema, message)
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
def validate_type(schema, types, field)
|
284
|
+
friendly_types =
|
285
|
+
types.map { |t| FRIENDLY_TYPES[t] || t }.sort.uniq.join("/")
|
286
|
+
value = schema.data[field]
|
287
|
+
if !value.nil? && !types.any? { |t| value.is_a?(t) }
|
288
|
+
message = %{Expected "#{field}" to be of type "#{friendly_types}"; value was: #{value.inspect}.}
|
289
|
+
@errors << SchemaError.new(schema, message)
|
290
|
+
nil
|
291
|
+
else
|
292
|
+
value
|
293
|
+
end
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|