json_schema 0.0.7 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  A JSON Schema V4 and Hyperschema V4 parser and validator.
4
4
 
5
+ Validate some data based on a JSON Schema:
6
+
7
+ ```
8
+ gem install json_schema
9
+ validate-schema schema.json data.json
10
+ ```
11
+
12
+ ## Programmatic
13
+
5
14
  ``` ruby
6
15
  require "json"
7
16
  require "json_schema"
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../lib/commands/validate_schema"
6
+
7
+ def print_usage!
8
+ $stderr.puts "Usage: validate-schema <schema> <data>, ..."
9
+ $stderr.puts " validate-schema -d <data>, ..."
10
+ end
11
+
12
+ command = Commands::ValidateSchema.new
13
+
14
+ OptionParser.new { |opts|
15
+ opts.on("-d", "--detect", "Detect schema from $schema") do
16
+ command.detect = true
17
+
18
+ # mix in common schemas for convenience
19
+ command.extra_schemas += ["schema.json", "hyper-schema.json"].
20
+ map { |f| File.expand_path(f, __FILE__ + "/../../schemas") }
21
+ end
22
+ opts.on("-s", "--schema SCHEMA", "Additional schema to use for references") do |s|
23
+ command.extra_schemas << s
24
+ end
25
+ }.parse!
26
+
27
+ success = command.run(ARGV.dup)
28
+
29
+ if success
30
+ command.messages.each { |m| $stdout.puts(m) }
31
+ elsif !command.errors.empty?
32
+ command.errors.each { |e| $stderr.puts(File.basename(__FILE__) + ": " + e) }
33
+ exit(1)
34
+ else
35
+ print_usage!
36
+ exit(1)
37
+ end
@@ -0,0 +1,102 @@
1
+ require_relative "../json_schema"
2
+
3
+ module Commands
4
+ class ValidateSchema
5
+ attr_accessor :detect
6
+ attr_accessor :extra_schemas
7
+
8
+ attr_accessor :errors
9
+ attr_accessor :messages
10
+
11
+ def initialize
12
+ @detect = false
13
+ @extra_schemas = []
14
+
15
+ @errors = []
16
+ @messages = []
17
+ end
18
+
19
+ def run(argv)
20
+ return false if !initialize_store
21
+
22
+ if !detect
23
+ return false if !(schema_file = argv.shift)
24
+ return false if !(schema = parse(schema_file))
25
+ end
26
+
27
+ # if there are no remaining files in arguments, also a problem
28
+ return false if argv.count < 1
29
+
30
+ argv.each do |data_file|
31
+ return false if !check_file(data_file)
32
+ data = JSON.parse(File.read(data_file))
33
+
34
+ if detect
35
+ if !(schema_uri = data["$schema"])
36
+ @errors = ["#{data_file}: No $schema tag for detection."]
37
+ return false
38
+ end
39
+
40
+ if !(schema = @store.lookup_uri(schema_uri))
41
+ @errors = ["#{data_file}: Unknown $schema, try specifying one with -s."]
42
+ return false
43
+ end
44
+ end
45
+
46
+ valid, errors = schema.validate(data)
47
+
48
+ if valid
49
+ @messages += ["#{data_file} is valid."]
50
+ else
51
+ errors = ["Invalid."] + errors.map { |e| e.message }
52
+ @errors += errors.map { |e| "#{data_file}: #{e}" }
53
+ end
54
+ end
55
+
56
+ @errors.empty?
57
+ end
58
+
59
+ private
60
+
61
+ def check_file(file)
62
+ if !File.exists?(file)
63
+ @errors = ["#{file}: No such file or directory."]
64
+ false
65
+ else
66
+ true
67
+ end
68
+ end
69
+
70
+ def initialize_store
71
+ @store = JsonSchema::DocumentStore.new
72
+ extra_schemas.each do |extra_schema|
73
+ if !(extra_schema = parse(extra_schema))
74
+ return false
75
+ end
76
+ @store.add_uri_reference(extra_schema.uri, extra_schema)
77
+ end
78
+ true
79
+ end
80
+
81
+ def parse(file)
82
+ return nil if !check_file(file)
83
+
84
+ parser = JsonSchema::Parser.new
85
+ if !(schema = parser.parse(JSON.parse(File.read(file))))
86
+ @errors = ["Schema is invalid."] + parser.errors.map { |e| e.message }
87
+ @errors.map! { |e| "#{file}: #{e}" }
88
+ return nil
89
+ end
90
+
91
+ expander = JsonSchema::ReferenceExpander.new
92
+ if !expander.expand(schema, store: @store)
93
+ @errors = ["Could not expand schema references."] +
94
+ expander.errors.map { |e| e.message }
95
+ @errors.map! { |e| "#{file}: #{e}" }
96
+ return nil
97
+ end
98
+
99
+ schema
100
+ end
101
+ end
102
+ end
@@ -1,8 +1,14 @@
1
- require "json_pointer"
2
1
  require "uri"
2
+ require_relative "json_pointer"
3
3
 
4
4
  module JsonReference
5
+ def self.reference(ref)
6
+ Reference.new(ref)
7
+ end
8
+
5
9
  class Reference
10
+ include Comparable
11
+
6
12
  attr_accessor :pointer
7
13
  attr_accessor :uri
8
14
 
@@ -14,12 +20,22 @@ module JsonReference
14
20
  if uri && !uri.empty?
15
21
  @uri = URI.parse(uri)
16
22
  end
23
+ @pointer ||= ""
17
24
  else
18
25
  @pointer = ref
19
26
  end
20
27
 
21
- # normalize pointers by prepending "#"
28
+ # normalize pointers by prepending "#" and stripping trailing "/"
22
29
  @pointer = "#" + @pointer
30
+ @pointer = @pointer.chomp("/")
31
+ end
32
+
33
+ def <=>(other)
34
+ to_s <=> other.to_s
35
+ end
36
+
37
+ def inspect
38
+ "\#<JsonReference::Reference #{to_s}>"
23
39
  end
24
40
 
25
41
  # Given the document addressed by #uri, resolves the JSON Pointer part of
data/lib/json_schema.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require_relative "json_schema/document_store"
1
2
  require_relative "json_schema/parser"
2
3
  require_relative "json_schema/reference_expander"
3
4
  require_relative "json_schema/schema"
@@ -0,0 +1,48 @@
1
+ module JsonSchema
2
+ # The document store helps resolve URI-based JSON pointers by storing IDs
3
+ # that we've seen in the schema.
4
+ #
5
+ # Each URI tuple also contains a pointer map that helps speed up expansions
6
+ # that have already happened and handles cyclic dependencies. Store a
7
+ # reference to the top-level schema before doing anything else.
8
+ class DocumentStore
9
+ def initialize
10
+ @uri_map = {}
11
+ end
12
+
13
+ def add_pointer_reference(uri, path, schema)
14
+ raise "can't add nil URI" if uri.nil?
15
+
16
+ if !@uri_map[uri][:pointer_map].key?(path)
17
+ @uri_map[uri][:pointer_map][path] = schema
18
+ end
19
+ end
20
+
21
+ def add_uri_reference(uri, schema)
22
+ raise "can't add nil URI" if uri.nil?
23
+
24
+ # Children without an ID keep the same URI as their parents. So since we
25
+ # traverse trees from top to bottom, just keep the first reference.
26
+ if !@uri_map.key?(uri)
27
+ @uri_map[uri] = {
28
+ pointer_map: {
29
+ JsonReference.reference("#").to_s => schema
30
+ },
31
+ schema: schema
32
+ }
33
+ end
34
+ end
35
+
36
+ def lookup_pointer(uri, pointer)
37
+ @uri_map[uri][:pointer_map][pointer]
38
+ end
39
+
40
+ def lookup_uri(uri)
41
+ if @uri_map[uri]
42
+ @uri_map[uri][:schema]
43
+ else
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,4 +1,4 @@
1
- require "json_reference"
1
+ require_relative "../json_reference"
2
2
 
3
3
  module JsonSchema
4
4
  class Parser
@@ -45,20 +45,38 @@ module JsonSchema
45
45
  def build_uri(id, parent_uri)
46
46
  # kill any trailing slashes
47
47
  if id
48
- id = id.chomp("/")
49
- end
50
-
48
+ # may look like: http://json-schema.org/draft-04/hyper-schema#
49
+ uri = URI.parse(id)
50
+ # make sure there is no `#` suffix
51
+ uri.fragment = nil
52
+ # if id is defined as absolute, the schema's URI stays absolute
53
+ if uri.absolute? || uri.path[0] == "/"
54
+ uri.to_s.chomp("/")
55
+ # otherwise build it according to the parent's URI
56
+ elsif parent_uri
57
+ # make sure we don't end up with duplicate slashes
58
+ parent_uri = parent_uri.chomp("/")
59
+ parent_uri + "/" + id
60
+ else
61
+ "/"
62
+ end
51
63
  # if id is missing, it's defined as its parent schema's URI
52
- if id.nil?
64
+ elsif parent_uri
53
65
  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
66
  else
59
- # make sure we don't end up with duplicate slashes
60
- parent_uri = parent_uri.chomp("/")
61
- parent_uri + "/" + id
67
+ "/"
68
+ end
69
+ end
70
+
71
+ def parse_additional_properties(schema)
72
+ if schema.additional_properties
73
+ # an object indicates a schema that will be used to parse any
74
+ # properties not listed in `properties`
75
+ if schema.additional_properties.is_a?(Hash)
76
+ schema.additional_properties =
77
+ parse_data(schema.additional_properties, schema)
78
+ end
79
+ # otherwise, leave as boolean
62
80
  end
63
81
  end
64
82
 
@@ -201,8 +219,11 @@ module JsonSchema
201
219
  schema.data = data
202
220
  schema.id = validate_type(schema, [String], "id")
203
221
 
222
+ # any parsed schema is automatically expanded
223
+ schema.expanded = true
224
+
204
225
  # build URI early so we can reference it in errors
205
- schema.uri = parent ? build_uri(schema.id, parent.uri) : "/"
226
+ schema.uri = build_uri(schema.id, parent ? parent.uri : nil)
206
227
 
207
228
  schema.title = validate_type(schema, [String], "title")
208
229
  schema.description = validate_type(schema, [String], "description")
@@ -235,7 +256,7 @@ module JsonSchema
235
256
 
236
257
  # validation: object
237
258
  schema.additional_properties =
238
- validate_type(schema, BOOLEAN, "additionalProperties")
259
+ validate_type(schema, BOOLEAN + [Hash], "additionalProperties")
239
260
  schema.dependencies = validate_type(schema, [Hash], "dependencies") || {}
240
261
  schema.max_properties = validate_type(schema, [Integer], "maxProperties")
241
262
  schema.min_properties = validate_type(schema, [Integer], "minProperties")
@@ -256,6 +277,7 @@ module JsonSchema
256
277
  schema.path_start = validate_type(schema, [String], "pathStart")
257
278
  schema.read_only = validate_type(schema, BOOLEAN, "readOnly")
258
279
 
280
+ parse_additional_properties(schema)
259
281
  parse_all_of(schema)
260
282
  parse_any_of(schema)
261
283
  parse_one_of(schema)
@@ -1,35 +1,22 @@
1
- require "json_schema/parser"
2
1
  require "set"
3
2
 
4
3
  module JsonSchema
5
4
  class ReferenceExpander
6
5
  attr_accessor :errors
7
6
 
8
- def expand(schema)
7
+ def expand(schema, options = {})
9
8
  @errors = []
10
9
  @schema = schema
11
- @store = {}
12
- @unresolved_refs = Set.new
13
- last_num_unresolved_refs = 0
10
+ @store = options[:store] ||= DocumentStore.new
14
11
 
15
- loop do
16
- traverse_schema(schema)
12
+ @store.add_uri_reference("/", schema)
17
13
 
18
- # nothing left unresolved, we're done!
19
- if @unresolved_refs.count == 0
20
- break
21
- end
22
-
23
- # a new traversal pass still hasn't managed to resolved anymore
24
- # references; we're out of luck
25
- if @unresolved_refs.count == last_num_unresolved_refs
26
- refs = @unresolved_refs.to_a.join(", ")
27
- message = %{Couldn't resolve references (possible circular dependency): #{refs}.}
28
- @errors << SchemaError.new(schema, message)
29
- break
30
- end
14
+ traverse_schema(schema)
31
15
 
32
- last_num_unresolved_refs = @unresolved_refs.count
16
+ refs = unresolved_refs(schema).sort
17
+ if refs.count > 0
18
+ message = %{Couldn't resolve references: #{refs.to_a.join(", ")}.}
19
+ @errors << SchemaError.new(schema, message)
33
20
  end
34
21
 
35
22
  @errors.count == 0
@@ -44,65 +31,101 @@ module JsonSchema
44
31
 
45
32
  private
46
33
 
47
- def dereference(schema)
48
- ref = schema.reference
34
+ def dereference(ref_schema, ref_stack)
35
+ ref = ref_schema.reference
36
+
37
+ # detects a reference cycle
38
+ if ref_stack.include?(ref)
39
+ message = %{Reference cycle detected: #{ref_stack.sort.join(", ")}.}
40
+ @errors << SchemaError.new(ref_schema, message)
41
+ return false
42
+ end
43
+
44
+ new_schema = resolve_reference(ref_schema)
45
+ return false unless new_schema
46
+
47
+ # if the reference resolved to a new reference we need to continue
48
+ # dereferencing until we either hit a non-reference schema, or a
49
+ # reference which is already resolved
50
+ if new_schema.reference && !new_schema.expanded?
51
+ success = dereference(new_schema, ref_stack + [ref])
52
+ return false unless success
53
+ end
54
+
55
+ # copy new schema into existing one while preserving parent
56
+ parent = ref_schema.parent
57
+ ref_schema.copy_from(new_schema)
58
+ ref_schema.parent = parent
59
+
60
+ true
61
+ end
62
+
63
+ def resolve_pointer(ref_schema, uri_path, resolved_schema)
64
+ ref = ref_schema.reference
65
+
66
+ # we've already evaluated this precise URI/pointer combination before
67
+ if !(new_schema = @store.lookup_pointer(uri_path, ref.pointer.to_s))
68
+ data = JsonPointer.evaluate(resolved_schema.data, ref.pointer)
69
+
70
+ # couldn't resolve pointer within known schema; that's an error
71
+ if data.nil?
72
+ message = %{Couldn't resolve pointer "#{ref.pointer}".}
73
+ @errors << SchemaError.new(resolved_schema, message)
74
+ return
75
+ end
76
+
77
+ # parse a new schema and use the same parent node
78
+ new_schema = Parser.new.parse(data, ref_schema.parent)
79
+
80
+ # add the reference into our document store right away; it will
81
+ # eventually be fully expanded
82
+ @store.add_pointer_reference(uri_path, ref.pointer.to_s, new_schema)
83
+ else
84
+ # insert a clone record so that the expander knows to expand it when
85
+ # the schema traversal is finished
86
+ new_schema.clones << ref_schema
87
+ end
88
+
89
+ new_schema
90
+ end
91
+
92
+ def resolve_reference(ref_schema)
93
+ ref = ref_schema.reference
49
94
  uri = ref.uri
50
95
 
51
96
  if uri && uri.host
52
97
  scheme = uri.scheme || "http"
53
- message = %{Reference resolution over #{scheme} is not currently supported.}
54
- @errors << SchemaError.new(schema, message)
98
+ # allow resolution if something we've already parsed has claimed the
99
+ # full URL
100
+ if @store.lookup_uri(uri.to_s)
101
+ resolve_uri(ref_schema, uri.to_s)
102
+ else
103
+ message =
104
+ %{Reference resolution over #{scheme} is not currently supported.}
105
+ @errors << SchemaError.new(ref_schema, message)
106
+ nil
107
+ end
55
108
  # absolute
56
109
  elsif uri && uri.path[0] == "/"
57
- resolve(schema, uri.path, ref)
110
+ resolve_uri(ref_schema, uri.path)
58
111
  # relative
59
112
  elsif uri
60
113
  # build an absolute path using the URI of the current schema
61
- schema_uri = schema.uri.chomp("/")
62
- resolve(schema, schema_uri + "/" + uri.path, ref)
114
+ schema_uri = ref_schema.uri.chomp("/")
115
+ resolve_uri(ref_schema, schema_uri + "/" + uri.path)
63
116
  # just a JSON Pointer -- resolve against schema root
64
117
  else
65
- evaluate(schema, @schema, ref)
66
- end
67
- end
68
-
69
- def evaluate(schema, schema_context, ref)
70
- data = JsonPointer.evaluate(schema_context.data, ref.pointer)
71
-
72
- # couldn't resolve pointer within known schema; that's an error
73
- if data.nil?
74
- message = %{Couldn't resolve pointer "#{ref.pointer}".}
75
- @errors << SchemaError.new(schema_context, message)
76
- return
77
- end
78
-
79
- # this counts as a resolution
80
- @unresolved_refs.delete(ref.to_s)
81
-
82
- # parse a new schema and use the same parent node
83
- new_schema = Parser.new.parse(data, schema.parent)
84
-
85
- # mark a new unresolved reference if the schema we got back is also a
86
- # reference
87
- if new_schema.reference
88
- @unresolved_refs.add(new_schema.reference.to_s)
118
+ resolve_pointer(ref_schema, "/", @schema)
89
119
  end
90
-
91
- # copy new schema into existing one while preserving parent
92
- parent = schema.parent
93
- schema.copy_from(new_schema)
94
- schema.parent = parent
95
-
96
- new_schema
97
120
  end
98
121
 
99
- def resolve(schema, uri, ref)
100
- if schema_context = @store[uri]
101
- evaluate(schema, schema_context, ref)
122
+ def resolve_uri(ref_schema, uri_path)
123
+ if schema = @store.lookup_uri(uri_path)
124
+ resolve_pointer(ref_schema, uri_path, schema)
102
125
  else
103
- # couldn't resolve, return original reference
104
- @unresolved_refs.add(ref.to_s)
105
- schema
126
+ message = %{Couldn't resolve URI: #{uri_path}.}
127
+ @errors << SchemaError.new(ref_schema, message)
128
+ nil
106
129
  end
107
130
  end
108
131
 
@@ -116,17 +139,23 @@ module JsonSchema
116
139
  schema.pattern_properties.each { |_, s| yielder << s }
117
140
  schema.properties.each { |_, s| yielder << s }
118
141
 
142
+ if additional = schema.additional_properties
143
+ if additional.is_a?(Schema)
144
+ yielder << additional
145
+ end
146
+ end
147
+
119
148
  if schema.not
120
149
  yielder << schema.not
121
150
  end
122
151
 
123
152
  # can either be a single schema (list validation) or multiple (tuple
124
153
  # validation)
125
- if schema.items
126
- if schema.items.is_a?(Array)
127
- schema.items.each { |s| yielder << s }
154
+ if items = schema.items
155
+ if items.is_a?(Array)
156
+ items.each { |s| yielder << s }
128
157
  else
129
- yielder << schema.items
158
+ yielder << items
130
159
  end
131
160
  end
132
161
 
@@ -138,18 +167,40 @@ module JsonSchema
138
167
  end
139
168
  end
140
169
 
141
- def traverse_schema(schema)
142
- # Children without an ID keep the same URI as their parents. So since we
143
- # traverse trees from top to bottom, just keep the first reference.
144
- if !@store.key?(schema.uri)
145
- @store[schema.uri] = schema
170
+ def unresolved_refs(schema)
171
+ # prevent endless recursion
172
+ return [] unless schema.original?
173
+
174
+ schema_children(schema).reduce([]) do |arr, subschema|
175
+ if !subschema.expanded?
176
+ arr += [subschema.reference]
177
+ else
178
+ arr += unresolved_refs(subschema)
179
+ end
146
180
  end
181
+ end
182
+
183
+ def traverse_schema(schema)
184
+ @store.add_uri_reference(schema.uri, schema)
147
185
 
148
186
  schema_children(schema).each do |subschema|
149
- if subschema.reference
150
- dereference(subschema)
187
+ if subschema.reference && !subschema.expanded?
188
+ dereference(subschema, [])
189
+ end
190
+
191
+ # traverse child schemas only if they're the original copy
192
+ if subschema.expanded? && subschema.original?
193
+ traverse_schema(subschema)
194
+ end
195
+ end
196
+
197
+ # after finishing a schema traversal, find all clones and re-hydrate them
198
+ if schema.original?
199
+ schema.clones.each do |clone_schema|
200
+ parent = clone_schema.parent
201
+ clone_schema.copy_from(schema)
202
+ clone_schema.parent = parent
151
203
  end
152
- traverse_schema(subschema)
153
204
  end
154
205
  end
155
206
  end