json_schema 0.0.7

Sign up to get free protection for your applications and to get access to all the features.
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