json_schema 0.0.7 → 0.0.9

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 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