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 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
+ ```
@@ -0,0 +1,7 @@
1
+ require_relative "json_pointer/evaluator"
2
+
3
+ module JsonPointer
4
+ def self.evaluate(data, path)
5
+ Evaluator.new(data).evaluate(path)
6
+ end
7
+ end
@@ -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
@@ -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