jsi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,127 @@
1
+ module JSI
2
+ # base class for representing an instance of an instance described by a schema
3
+ class Base
4
+ class << self
5
+ def class_comment
6
+ lines = []
7
+
8
+ description = schema &&
9
+ schema['description'].respond_to?(:to_str) &&
10
+ schema['description'].to_str
11
+ if description
12
+ description.split("\n", -1).each do |descline|
13
+ lines << "# " + descline
14
+ end
15
+ lines << "#"
16
+ end
17
+
18
+ schema.described_hash_property_names.each_with_index do |propname, i|
19
+ lines << "#" unless i == 0
20
+ lines << "# @!attribute [rw] #{propname}"
21
+
22
+ property_schema = schema['properties'].respond_to?(:to_hash) &&
23
+ schema['properties'][propname].respond_to?(:to_hash) &&
24
+ schema['properties'][propname]
25
+
26
+ required = property_schema && property_schema['required']
27
+ required ||= schema['required'].respond_to?(:to_ary) && schema['required'].include?(propname)
28
+ lines << "# @required" if required
29
+
30
+ type = property_schema &&
31
+ property_schema['type'].respond_to?(:to_str) &&
32
+ property_schema['type'].to_str
33
+ simple = {'string' => 'String', 'number' => 'Numeric', 'boolean' => 'Boolean', 'null' => 'nil'}
34
+ rettypes = []
35
+ if simple.key?(type)
36
+ rettypes << simple[type]
37
+ elsif type == 'object' || type == 'array'
38
+ rettypes = []
39
+ schema_class = JSI.class_for_schema(property_schema)
40
+ unless schema_class.name =~ /\AJSI::SchemaClasses::/
41
+ rettypes << schema_class.name
42
+ end
43
+ rettypes << {'object' => '#to_hash', 'array' => '#to_ary'}[type]
44
+ elsif type
45
+ # not really valid, but there's some information in there. whatever it is.
46
+ rettypes << type
47
+ end
48
+ # we'll add Object to all because the accessor methods have no enforcement that their value is
49
+ # of the specified type, and may return anything really. TODO: consider if this is of any value?
50
+ rettypes << 'Object'
51
+ lines << "# @return [#{rettypes.join(', ')}]"
52
+
53
+ description = property_schema &&
54
+ property_schema['description'].respond_to?(:to_str) &&
55
+ property_schema['description'].to_str
56
+ if description
57
+ description.split("\n", -1).each do |descline|
58
+ lines << "# " + descline
59
+ end
60
+ end
61
+ end
62
+ lines.join("\n")
63
+ end
64
+
65
+ def to_rb
66
+ lines = []
67
+ description = schema &&
68
+ schema['description'].respond_to?(:to_str) &&
69
+ schema['description'].to_str
70
+ if description
71
+ description.split("\n", -1).each do |descline|
72
+ lines << "# " + descline
73
+ end
74
+ end
75
+ lines << "class #{name}"
76
+ schema.described_hash_property_names.each_with_index do |propname, i|
77
+ lines << "" unless i == 0
78
+ property_schema = schema['properties'].respond_to?(:to_hash) &&
79
+ schema['properties'][propname].respond_to?(:to_hash) &&
80
+ schema['properties'][propname]
81
+ description = property_schema &&
82
+ property_schema['description'].respond_to?(:to_str) &&
83
+ property_schema['description'].to_str
84
+ if description
85
+ description.split("\n", -1).each do |descline|
86
+ lines << " # " + descline
87
+ end
88
+ lines << " #" # blank comment line between description and @return
89
+ end
90
+
91
+ required = property_schema && property_schema['required']
92
+ required ||= schema['required'].respond_to?(:to_ary) && schema['required'].include?(propname)
93
+ lines << " # @required" if required
94
+
95
+ type = property_schema &&
96
+ property_schema['type'].respond_to?(:to_str) &&
97
+ property_schema['type'].to_str
98
+ simple = {'string' => 'String', 'number' => 'Numeric', 'boolean' => 'Boolean', 'null' => 'nil'}
99
+ rettypes = []
100
+ if simple.key?(type)
101
+ rettypes << simple[type]
102
+ elsif type == 'object' || type == 'array'
103
+ rettypes = []
104
+ schema_class = JSI.class_for_schema(property_schema)
105
+ unless schema_class.name =~ /\AJSI::SchemaClasses::/
106
+ rettypes << schema_class.name
107
+ end
108
+ rettypes << {'object' => '#to_hash', 'array' => '#to_ary'}[type]
109
+ elsif type
110
+ # not really valid, but there's some information in there. whatever it is.
111
+ rettypes << type
112
+ end
113
+ # we'll add Object to all because the accessor methods have no enforcement that their value is
114
+ # of the specified type, and may return anything really. TODO: consider if this is of any value?
115
+ rettypes << 'Object'
116
+ lines << " # @return [#{rettypes.join(', ')}]"
117
+
118
+ lines << " def #{propname}"
119
+ lines << " super"
120
+ lines << " end"
121
+ end
122
+ lines << "end"
123
+ lines.join("\n")
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,191 @@
1
+ require "json-schema"
2
+
3
+ # apply the changes from https://github.com/ruby-json-schema/json-schema/pull/382
4
+
5
+ # json-schema/pointer.rb
6
+ require 'addressable/uri'
7
+
8
+ module JSON
9
+ class Schema
10
+ # a JSON Pointer, as described by RFC 6901 https://tools.ietf.org/html/rfc6901
11
+ class Pointer
12
+ class Error < JSON::Schema::SchemaError
13
+ end
14
+ class PointerSyntaxError < Error
15
+ end
16
+ class ReferenceError < Error
17
+ end
18
+
19
+ # parse a fragment to an array of reference tokens
20
+ #
21
+ # #/foo/bar
22
+ #
23
+ # => ['foo', 'bar']
24
+ #
25
+ # #/foo%20bar
26
+ #
27
+ # => ['foo bar']
28
+ def self.parse_fragment(fragment)
29
+ fragment = Addressable::URI.unescape(fragment)
30
+ match = fragment.match(/\A#/)
31
+ if match
32
+ parse_pointer(match.post_match)
33
+ else
34
+ raise(PointerSyntaxError, "Invalid fragment syntax in #{fragment.inspect}: fragment must begin with #")
35
+ end
36
+ end
37
+
38
+ # parse a pointer to an array of reference tokens
39
+ #
40
+ # /foo
41
+ #
42
+ # => ['foo']
43
+ #
44
+ # /foo~0bar/baz~1qux
45
+ #
46
+ # => ['foo~bar', 'baz/qux']
47
+ def self.parse_pointer(pointer_string)
48
+ tokens = pointer_string.split('/', -1).map! do |piece|
49
+ piece.gsub('~1', '/').gsub('~0', '~')
50
+ end
51
+ if tokens[0] == ''
52
+ tokens[1..-1]
53
+ elsif tokens.empty?
54
+ tokens
55
+ else
56
+ raise(PointerSyntaxError, "Invalid pointer syntax in #{pointer_string.inspect}: pointer must begin with /")
57
+ end
58
+ end
59
+
60
+ # initializes a JSON::Schema::Pointer from the given representation.
61
+ #
62
+ # type may be one of:
63
+ #
64
+ # - :fragment - the representation is a fragment containing a pointer (starting with #)
65
+ # - :pointer - the representation is a pointer (starting with /)
66
+ # - :reference_tokens - the representation is an array of tokens referencing a path in a document
67
+ def initialize(type, representation)
68
+ @type = type
69
+ if type == :reference_tokens
70
+ reference_tokens = representation
71
+ elsif type == :fragment
72
+ reference_tokens = self.class.parse_fragment(representation)
73
+ elsif type == :pointer
74
+ reference_tokens = self.class.parse_pointer(representation)
75
+ else
76
+ raise ArgumentError, "invalid initialization type: #{type.inspect} with representation #{representation.inspect}"
77
+ end
78
+ @reference_tokens = reference_tokens.map(&:freeze).freeze
79
+ end
80
+
81
+ attr_reader :reference_tokens
82
+
83
+ # takes a root json document and evaluates this pointer through the document, returning the value
84
+ # pointed to by this pointer.
85
+ def evaluate(document)
86
+ reference_tokens.inject(document) do |value, token|
87
+ if value.is_a?(Array)
88
+ if token.is_a?(String) && token =~ /\A\d|[1-9]\d+\z/
89
+ token = token.to_i
90
+ end
91
+ unless token.is_a?(Integer)
92
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not an integer and cannot be resolved in array #{value.inspect}")
93
+ end
94
+ unless (0...value.size).include?(token)
95
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not a valid index of #{value.inspect}")
96
+ end
97
+ elsif value.is_a?(Hash)
98
+ unless value.key?(token)
99
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} is not a valid key of #{value.inspect}")
100
+ end
101
+ else
102
+ raise(ReferenceError, "Invalid resolution for #{to_s}: #{token.inspect} cannot be resolved in #{value.inspect}")
103
+ end
104
+ value[token]
105
+ end
106
+ end
107
+
108
+ # the pointer string representation of this Pointer
109
+ def pointer
110
+ reference_tokens.map { |t| '/' + t.to_s.gsub('~', '~0').gsub('/', '~1') }.join('')
111
+ end
112
+
113
+ # the fragment string representation of this Pointer
114
+ def fragment
115
+ '#' + Addressable::URI.escape(pointer)
116
+ end
117
+
118
+ def to_s
119
+ "#<#{self.class.inspect} #{@type} = #{representation_s}>"
120
+ end
121
+
122
+ private
123
+
124
+ def representation_s
125
+ if @type == :fragment
126
+ fragment
127
+ elsif @type == :pointer
128
+ pointer
129
+ else
130
+ reference_tokens.inspect
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # json-schema/validator.rb
138
+
139
+ module JSON
140
+ class Validator
141
+ def initialize(schema_data, data, opts={})
142
+ @options = @@default_opts.clone.merge(opts)
143
+ @errors = []
144
+
145
+ validator = self.class.validator_for_name(@options[:version])
146
+ @options[:version] = validator
147
+ @options[:schema_reader] ||= self.class.schema_reader
148
+
149
+ @validation_options = @options[:record_errors] ? {:record_errors => true} : {}
150
+ @validation_options[:insert_defaults] = true if @options[:insert_defaults]
151
+ @validation_options[:strict] = true if @options[:strict] == true
152
+ @validation_options[:clear_cache] = true if !@@cache_schemas || @options[:clear_cache]
153
+
154
+ @@mutex.synchronize { @base_schema = initialize_schema(schema_data) }
155
+ @original_data = data
156
+ @data = initialize_data(data)
157
+ @@mutex.synchronize { build_schemas(@base_schema) }
158
+
159
+ # If the :fragment option is set, try and validate against the fragment
160
+ if opts[:fragment]
161
+ @base_schema = schema_from_fragment(@base_schema, opts[:fragment])
162
+ end
163
+
164
+ # validate the schema, if requested
165
+ if @options[:validate_schema]
166
+ if @base_schema.schema["$schema"]
167
+ base_validator = self.class.validator_for_name(@base_schema.schema["$schema"])
168
+ end
169
+ metaschema = base_validator ? base_validator.metaschema : validator.metaschema
170
+ # Don't clear the cache during metaschema validation!
171
+ self.class.validate!(metaschema, @base_schema.schema, {:clear_cache => false})
172
+ end
173
+ end
174
+
175
+ def schema_from_fragment(base_schema, fragment)
176
+ schema_uri = base_schema.uri
177
+
178
+ pointer = JSON::Schema::Pointer.new(:fragment, fragment)
179
+
180
+ base_schema = JSON::Schema.new(pointer.evaluate(base_schema.schema), schema_uri, @options[:version])
181
+
182
+ if @options[:list]
183
+ base_schema.to_array_schema
184
+ elsif base_schema.is_a?(Hash)
185
+ JSON::Schema.new(base_schema, schema_uri, @options[:version])
186
+ else
187
+ base_schema
188
+ end
189
+ end
190
+ end
191
+ end
data/lib/jsi/json.rb ADDED
@@ -0,0 +1,7 @@
1
+ module JSI
2
+ module JSON
3
+ autoload :Node, 'jsi/json/node'
4
+ autoload :ArrayNode, 'jsi/json/node'
5
+ autoload :HashNode, 'jsi/json/node'
6
+ end
7
+ end
@@ -0,0 +1,256 @@
1
+ module JSI
2
+ module JSON
3
+ # JSI::JSON::Node is an abstraction of a node within a JSON document.
4
+ # it aims to act like the underlying data type of the node's content
5
+ # (Hash or Array, generally) in most cases, defining methods of Hash
6
+ # and Array which delegate to the content. However, destructive methods
7
+ # are not defined, as modifying the content of a node would change it
8
+ # for any other nodes in the document that contain or refer to it.
9
+ #
10
+ # methods that return a modified copy such as #merge are defined, and
11
+ # return a copy of the document with the content of the node modified.
12
+ # the original node's document and content are untouched.
13
+ class Node
14
+ # if the content of the document at the given path is a Hash, returns
15
+ # a HashNode; if an Array, returns ArrayNode. otherwise returns a
16
+ # regular Node, though, for the most part this will be called with Hash
17
+ # or Array content.
18
+ def self.new_by_type(document, path)
19
+ node = Node.new(document, path)
20
+ content = node.content
21
+ if content.is_a?(Hash)
22
+ HashNode.new(document, path)
23
+ elsif content.is_a?(Array)
24
+ ArrayNode.new(document, path)
25
+ else
26
+ node
27
+ end
28
+ end
29
+
30
+ # a Node represents the content of a document at a given path.
31
+ def initialize(document, path)
32
+ raise(ArgumentError, "path must be an array. got: #{path.pretty_inspect.chomp} (#{path.class})") unless path.is_a?(Array)
33
+ @document = document
34
+ @path = path.dup.freeze
35
+ @pointer = ::JSON::Schema::Pointer.new(:reference_tokens, path)
36
+ end
37
+
38
+ # the path of this Node within its document
39
+ attr_reader :path
40
+ # the document containing this Node at is path
41
+ attr_reader :document
42
+ # ::JSON::Schema::Pointer representing the path to this node within its document
43
+ attr_reader :pointer
44
+
45
+ # the raw content of this Node from the underlying document at this Node's path.
46
+ def content
47
+ pointer.evaluate(document)
48
+ end
49
+
50
+ def [](k)
51
+ node = self
52
+ content = node.content
53
+ if content.is_a?(Hash) && !content.key?(k)
54
+ node = node.deref
55
+ content = node.content
56
+ end
57
+ begin
58
+ el = content[k]
59
+ rescue TypeError => e
60
+ raise(e.class, e.message + "\nsubscripting with #{k.pretty_inspect.chomp} (#{k.class}) from #{content.class.inspect}. self is: #{pretty_inspect.chomp}", e.backtrace)
61
+ end
62
+ if el.is_a?(Hash) || el.is_a?(Array)
63
+ self.class.new_by_type(node.document, node.path + [k])
64
+ else
65
+ el
66
+ end
67
+ end
68
+
69
+ def []=(k, v)
70
+ if v.is_a?(Node)
71
+ content[k] = v.content
72
+ else
73
+ content[k] = v
74
+ end
75
+ end
76
+
77
+ def deref
78
+ content = self.content
79
+
80
+ return self unless content.is_a?(Hash) && content['$ref'].is_a?(String)
81
+
82
+ if content['$ref'][/\A#/]
83
+ return self.class.new_by_type(document, ::JSON::Schema::Pointer.parse_fragment(content['$ref'])).deref
84
+ end
85
+
86
+ # HAX for how google does refs and ids
87
+ if document_node['schemas'].respond_to?(:to_hash)
88
+ if document_node['schemas'][content['$ref']]
89
+ return document_node['schemas'][content['$ref']]
90
+ end
91
+ _, deref_by_id = document_node['schemas'].detect { |_k, schema| schema['id'] == content['$ref'] }
92
+ if deref_by_id
93
+ return deref_by_id
94
+ end
95
+ end
96
+
97
+ #raise(NotImplementedError, "cannot dereference #{content['$ref']}") # TODO
98
+ return self
99
+ end
100
+
101
+ # a Node at the root of the document
102
+ def document_node
103
+ Node.new_by_type(document, [])
104
+ end
105
+
106
+ # the parent of this node. if this node is the document root (its path is empty), raises
107
+ # ::JSON::Schema::Pointer::ReferenceError.
108
+ def parent_node
109
+ if path.empty?
110
+ raise(::JSON::Schema::Pointer::ReferenceError, "cannot access parent of root node: #{pretty_inspect.chomp}")
111
+ else
112
+ Node.new_by_type(document, path[0...-1])
113
+ end
114
+ end
115
+
116
+ # the pointer path to this node within the document, per RFC 6901 https://tools.ietf.org/html/rfc6901
117
+ def pointer_path
118
+ pointer.pointer
119
+ end
120
+ # the pointer fragment to this node within the document, per RFC 6901 https://tools.ietf.org/html/rfc6901
121
+ def fragment
122
+ pointer.fragment
123
+ end
124
+
125
+ def as_json(*opt)
126
+ Typelike.as_json(content, *opt)
127
+ end
128
+
129
+ # takes a block. the block is yielded the content of this node. the block MUST return a modified
130
+ # copy of that content (and NOT modify the object it is given).
131
+ def modified_copy
132
+ # we need to preserve the rest of the document, but modify the content at our path.
133
+ #
134
+ # this is actually a bit tricky. we can't modify the original document, obviously.
135
+ # we could do a deep copy, but that's expensive. instead, we make a copy of each array
136
+ # or hash in the path above this node. this node's content is modified by the caller, and
137
+ # that is recursively merged up to the document root. the recursion is done with a
138
+ # y combinator, for no other reason than that was a fun way to implement it.
139
+ modified_document = JSI.ycomb do |rec|
140
+ proc do |subdocument, subpath|
141
+ if subpath == []
142
+ yield(subdocument)
143
+ else
144
+ car = subpath[0]
145
+ cdr = subpath[1..-1]
146
+ if subdocument.respond_to?(:to_hash)
147
+ car_object = rec.call(subdocument[car], cdr)
148
+ if car_object.object_id == subdocument[car].object_id
149
+ subdocument
150
+ else
151
+ subdocument.merge({car => car_object})
152
+ end
153
+ elsif subdocument.respond_to?(:to_ary)
154
+ if car.is_a?(String) && car =~ /\A\d+\z/
155
+ car = car.to_i
156
+ end
157
+ unless car.is_a?(Integer)
158
+ raise(TypeError, "bad subscript #{car.pretty_inspect.chomp} with remaining subpath: #{cdr.inspect} for array: #{subdocument.pretty_inspect.chomp}")
159
+ end
160
+ car_object = rec.call(subdocument[car], cdr)
161
+ if car_object.object_id == subdocument[car].object_id
162
+ subdocument
163
+ else
164
+ subdocument.dup.tap do |arr|
165
+ arr[car] = car_object
166
+ end
167
+ end
168
+ else
169
+ raise(TypeError, "bad subscript: #{car.pretty_inspect.chomp} with remaining subpath: #{cdr.inspect} for content: #{subdocument.pretty_inspect.chomp}")
170
+ end
171
+ end
172
+ end
173
+ end.call(document, path)
174
+ Node.new_by_type(modified_document, path)
175
+ end
176
+
177
+ def object_group_text
178
+ "fragment=#{fragment.inspect}"
179
+ end
180
+ def inspect
181
+ "\#<#{self.class.inspect} #{object_group_text} #{content.inspect}>"
182
+ end
183
+ def pretty_print(q)
184
+ q.instance_exec(self) do |obj|
185
+ text "\#<#{obj.class.inspect} #{obj.object_group_text}"
186
+ group_sub {
187
+ nest(2) {
188
+ breakable ' '
189
+ pp obj.content
190
+ }
191
+ }
192
+ breakable ''
193
+ text '>'
194
+ end
195
+ end
196
+
197
+ def fingerprint
198
+ {is_node: self.is_a?(JSI::JSON::Node), document: document, path: path}
199
+ end
200
+ include FingerprintHash
201
+ end
202
+
203
+ class ArrayNode < Node
204
+ def each
205
+ return to_enum(__method__) { content.size } unless block_given?
206
+ content.each_index { |i| yield self[i] }
207
+ self
208
+ end
209
+
210
+ def to_ary
211
+ to_a
212
+ end
213
+
214
+ include Enumerable
215
+ include Arraylike
216
+
217
+ def as_json(*opt) # needs redefined after including Enumerable
218
+ Typelike.as_json(content, *opt)
219
+ end
220
+
221
+ # methods that don't look at the value; can skip the overhead of #[] (invoked by #to_a).
222
+ # we override these methods from Arraylike
223
+ SAFE_INDEX_ONLY_METHODS.each do |method_name|
224
+ define_method(method_name) { |*a, &b| content.public_send(method_name, *a, &b) }
225
+ end
226
+ end
227
+
228
+ class HashNode < Node
229
+ def each(&block)
230
+ return to_enum(__method__) { content.size } unless block_given?
231
+ if block.arity > 1
232
+ content.each_key { |k| yield k, self[k] }
233
+ else
234
+ content.each_key { |k| yield [k, self[k]] }
235
+ end
236
+ self
237
+ end
238
+
239
+ def to_hash
240
+ inject({}) { |h, (k, v)| h[k] = v; h }
241
+ end
242
+
243
+ include Enumerable
244
+ include Hashlike
245
+
246
+ def as_json(*opt) # needs redefined after including Enumerable
247
+ Typelike.as_json(content, *opt)
248
+ end
249
+
250
+ # methods that don't look at the value; can skip the overhead of #[] (invoked by #to_hash)
251
+ SAFE_KEY_ONLY_METHODS.each do |method_name|
252
+ define_method(method_name) { |*a, &b| content.public_send(method_name, *a, &b) }
253
+ end
254
+ end
255
+ end
256
+ end