j2119 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,312 @@
1
+ # Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may
4
+ # not use this file except in compliance with the License. A copy of the
5
+ # License is located at
6
+ #
7
+ # http://aws.amazon.com/apache2.0/
8
+ #
9
+ # or in the LICENSE.txt file accompanying this file. This file is distributed
10
+ # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11
+ # express or implied. See the License for the specific language governing
12
+ # permissions and limitations under the License.
13
+
14
+ require 'date'
15
+
16
+ module J2119
17
+
18
+ # These all respond_to
19
+ # check(node, path, problem)
20
+ # - node is the JSON node being checked
21
+ # - path is the current path, for reporting practices
22
+ # - problems is a list of problem reports
23
+ # TODO: Add a "role" argument to enrich error reporting
24
+ class Constraint
25
+ def initialize
26
+ @conditions = []
27
+ end
28
+
29
+ def add_condition(condition)
30
+ @conditions << condition
31
+ end
32
+
33
+ def applies(node, role)
34
+ return @conditions.empty? ||
35
+ @conditions.map{|c| c.constraint_applies(node, role)}.any?
36
+ end
37
+ end
38
+
39
+ # Verify that there is only one of a selection of fields
40
+ #
41
+ class OnlyOneOfConstraint < Constraint
42
+ def initialize(fields)
43
+ super()
44
+ @fields = fields
45
+ end
46
+
47
+ def check(node, path, problems)
48
+ if (@fields & node.keys).size > 1
49
+ problems <<
50
+ "#{path} may have only one of #{@fields}"
51
+ end
52
+ end
53
+ end
54
+
55
+ # Verify that array field is not empty
56
+ #
57
+ class NonEmptyConstraint < Constraint
58
+ def initialize(name)
59
+ super()
60
+ @name = name
61
+ end
62
+
63
+ def to_s
64
+ conds = (@conditions.empty?) ? '' : " #{@conditions.size} conditions"
65
+ "<Array field #{@name} should not be empty#{conds}>"
66
+ end
67
+
68
+ def check(node, path, problems)
69
+ if node[@name] && node[@name].is_a?(Array) && (node[@name].size == 0)
70
+ problems <<
71
+ "#{path}.#{@name} is empty, non-empty required"
72
+ end
73
+ end
74
+ end
75
+
76
+ # Verify node has the named field, or one of the named fields
77
+ #
78
+ class HasFieldConstraint < Constraint
79
+ def initialize(name)
80
+ super()
81
+ if name.is_a?(String)
82
+ @names = [ name ]
83
+ else
84
+ @names = name
85
+ end
86
+ end
87
+
88
+ def to_s
89
+ conds = (@conditions.empty?) ? '' : " #{@conditions.size} conditions"
90
+ "<Field #{@names} should be present#{conds}>"
91
+ end
92
+
93
+ def check(node, path, problems)
94
+ if (node.keys & @names).empty?
95
+ if @names.size == 1
96
+ problems <<
97
+ "#{path} does not have required field \"#{@names[0]}\""
98
+ else
99
+ problems <<
100
+ "#{path} does not have required field from #{@names}"
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # Verify node does not have the named field
107
+ #
108
+ class DoesNotHaveFieldConstraint < Constraint
109
+ def initialize(name)
110
+ super()
111
+ @name = name
112
+ end
113
+
114
+ def to_s
115
+ conds = (@conditions.empty?) ? '' : " #{@conditions.size} conditions"
116
+ "<Field #{@name} should be absent#{conds}>"
117
+ end
118
+
119
+ def check(node, path, problems)
120
+ if node[@name]
121
+ problems <<
122
+ "#{path} has forbidden field \"#{@name}\""
123
+ end
124
+ end
125
+ end
126
+
127
+ # Verify type of a field in a node
128
+ #
129
+ class FieldTypeConstraint < Constraint
130
+ def initialize(name, type, is_array, is_nullable)
131
+ super()
132
+ @name = name
133
+ @type = type
134
+ @is_array = is_array
135
+ @is_nullable = is_nullable
136
+ end
137
+
138
+ def to_s
139
+ conds = (@conditions.empty?) ? '' : " #{@conditions.size} conditions"
140
+ nullable = @is_nullable ? ' (nullable)' : ''
141
+ "<Field #{@name} should be of type #{@type}#{conds}>#{nullable}"
142
+ end
143
+
144
+ def check(node, path, problems)
145
+
146
+ # type-checking is orthogonal to existence checking
147
+ return if !node.key?(@name)
148
+
149
+ value = node[@name]
150
+ path = "#{path}.#{@name}"
151
+
152
+ if value == nil
153
+ if !@is_nullable
154
+ problems << "#{path} should be non-null"
155
+ end
156
+ return
157
+ end
158
+
159
+ if @is_array
160
+ if value.is_a?(Array)
161
+ i = 0
162
+ value.each do |element|
163
+ value_check(element, "#{path}[#{i}]", problems)
164
+ i += 1
165
+ end
166
+ else
167
+ report(path, value, 'an Array', problems)
168
+ end
169
+ else
170
+ value_check(value, path, problems)
171
+ end
172
+ end
173
+
174
+ def value_check(value, path, problems)
175
+ case @type
176
+ when :object
177
+ report(path, value, 'an Object', problems) if !value.is_a?(Hash)
178
+ when :array
179
+ report(path, value, 'an Array', problems) if !value.is_a?(Array)
180
+ when :string
181
+ report(path, value, 'a String', problems) if !value.is_a?(String)
182
+ when :integer
183
+ report(path, value, 'an Integer', problems) if !value.is_a?(Integer)
184
+ when :float
185
+ report(path, value, 'a Float', problems) if !value.is_a?(Float)
186
+ when :boolean
187
+ if value != true && value != false
188
+ report(path, value, 'a Boolean', problems)
189
+ end
190
+ when :numeric
191
+ report(path, value, 'numeric', problems) if !value.is_a?(Numeric)
192
+ when :json_path
193
+ report(path, value, 'a JSONPath', problems) if !JSONPathChecker.is_path?(value)
194
+ when :reference_path
195
+ report(path, value, 'a Reference Path', problems) if !JSONPathChecker.is_reference_path?(value)
196
+ when :timestamp
197
+ begin
198
+ DateTime.rfc3339 value
199
+ rescue
200
+ report(path, value, 'an RFC3339 timestamp', problems)
201
+ end
202
+ when :URI
203
+ if !(value.is_a?(String) && (value =~ /^[a-z]+:/))
204
+ report(path, value, 'A URI', problems)
205
+ end
206
+ end
207
+
208
+ end
209
+
210
+ def report(path, value, message, problems)
211
+ if value.is_a?(String)
212
+ value = '"' + value + '"'
213
+ end
214
+ problems << "#{path} is #{value} but should be #{message}"
215
+ end
216
+
217
+ end
218
+
219
+ # Verify constraints on values of a named field
220
+ #
221
+ class FieldValueConstraint < Constraint
222
+
223
+ def initialize(name, params)
224
+ super()
225
+ @name = name
226
+ @params = params
227
+ end
228
+
229
+ def to_s
230
+ conds = (@conditions.empty?) ? '' : " #{@conditions.size} conditions"
231
+ "<Field #{@name} has constraints #{@params}#{conds}>"
232
+ end
233
+
234
+ def check(node, path, problems)
235
+
236
+ # value-checking is orthogonal to existence checking
237
+ return if !node.key?(@name)
238
+
239
+ value = node[@name]
240
+
241
+ if @params[:enum]
242
+ if !(@params[:enum].include?(value))
243
+ problems <<
244
+ "#{path}.#{@name} is \"#{value}\", " +
245
+ "not one of the allowed values #{@params[:enum]}"
246
+ end
247
+
248
+ # if enum constraint are provided, others are ignored
249
+ return
250
+ end
251
+
252
+ if @params[:equal]
253
+ begin
254
+ if value != @params[:equal]
255
+ problems <<
256
+ "#{path}.#{@name} is #{value} " +
257
+ "but required value is #{@params[:equal]}"
258
+ end
259
+ rescue Exception
260
+ # should be caught by type constraint
261
+ end
262
+ end
263
+ if @params[:floor]
264
+ begin
265
+ if value <= @params[:floor]
266
+ problems <<
267
+ "#{path}.#{@name} is #{value} " +
268
+ "but allowed floor is #{@params[:floor]}"
269
+ end
270
+ rescue Exception
271
+ # should be caught by type constraint
272
+ end
273
+ end
274
+ if @params[:min]
275
+ begin
276
+ if value < @params[:min]
277
+ problems <<
278
+ "#{path}.#{@name} is #{value} " +
279
+ "but allowed minimum is #{@params[:min]}"
280
+ end
281
+ rescue Exception
282
+ # should be caught by type constraint
283
+ end
284
+ end
285
+ if @params[:ceiling]
286
+ begin
287
+ if value >= @params[:ceiling]
288
+ problems <<
289
+ "#{path}.#{@name} is #{value} " +
290
+ "but allowed ceiling is #{@params[:ceiling]}"
291
+ end
292
+ rescue Exception
293
+ # should be caught by type constraint
294
+ end
295
+ end
296
+ if @params[:max]
297
+ begin
298
+ if value > @params[:max]
299
+ problems <<
300
+ "#{path}.#{@name} is #{value} " +
301
+ "but allowed maximum is #{@params[:max]}"
302
+ end
303
+ rescue Exception
304
+ # should be caught by type constraint
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ end
311
+
312
+
@@ -0,0 +1,39 @@
1
+ # Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may
4
+ # not use this file except in compliance with the License. A copy of the
5
+ # License is located at
6
+ #
7
+ # http://aws.amazon.com/apache2.0/
8
+ #
9
+ # or in the LICENSE.txt file accompanying this file. This file is distributed
10
+ # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11
+ # express or implied. See the License for the specific language governing
12
+ # permissions and limitations under the License.
13
+
14
+ module J2119
15
+
16
+ class Deduce
17
+
18
+ # we have to deduce the JSON value from they way they expressed it as
19
+ # text in the J2119 file.
20
+ #
21
+ def self.value(val)
22
+ case val
23
+ when /^"(.*)"$/
24
+ $1
25
+ when 'true'
26
+ true
27
+ when 'false'
28
+ false
29
+ when 'null'
30
+ nil
31
+ when /^\d+$/
32
+ val.to_i
33
+ else
34
+ val.to_f
35
+ end
36
+
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,58 @@
1
+ # coding: utf-8
2
+ # Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may
5
+ # not use this file except in compliance with the License. A copy of the
6
+ # License is located at
7
+ #
8
+ # http://aws.amazon.com/apache2.0/
9
+ #
10
+ # or in the LICENSE.txt file accompanying this file. This file is distributed
11
+ # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12
+ # express or implied. See the License for the specific language governing
13
+ # permissions and limitations under the License.
14
+
15
+ # Examines fields which are supposed to be JSONPath expressions or Reference
16
+ # Paths, which are JSONPaths that are singular, i.e. don't produce array
17
+ # results
18
+ #
19
+ module J2119
20
+
21
+ INITIAL_NAME_CLASSES = [ 'Lu', 'Ll', 'Lt', 'Lm', 'Lo', 'Nl' ]
22
+ NON_INITIAL_NAME_CLASSES = [ 'Mn', 'Mc', 'Nd', 'Pc' ]
23
+ FOLLOWING_NAME_CLASSES = INITIAL_NAME_CLASSES | NON_INITIAL_NAME_CLASSES
24
+ DOT_SEPARATOR = '\.\.?'
25
+
26
+ class JSONPathChecker
27
+
28
+ def self.classes_to_re classes
29
+ re_classes = classes.map {|x| "\\p{#{x}}" }
30
+ "[#{re_classes.join('')}]"
31
+ end
32
+
33
+ @@name_re = classes_to_re(INITIAL_NAME_CLASSES) +
34
+ classes_to_re(FOLLOWING_NAME_CLASSES) + '*'
35
+ dot_step = DOT_SEPARATOR + '((' + @@name_re + ')|(\*))'
36
+ rp_dot_step = DOT_SEPARATOR + @@name_re
37
+ bracket_step = '\[' + "'" + @@name_re + "'" + '\]'
38
+ rp_num_index = '\[\d+\]'
39
+ num_index = '\[\d+(, *\d+)?\]'
40
+ star_index = '\[\*\]'
41
+ colon_index = '\[(-?\d+)?:(-?\d+)?\]'
42
+ index = '((' + num_index + ')|(' + star_index + ')|(' + colon_index + '))'
43
+ step = '((' + dot_step + ')|(' + bracket_step + '))' + '(' + index + ')?'
44
+ rp_step = '((' + rp_dot_step + ')|(' + bracket_step + '))' + '(' + rp_num_index + ')?'
45
+ path = '^\$' + '(' + step + ')+$'
46
+ reference_path = '^\$' + '(' + rp_step + ')+$'
47
+ @@path_re = Regexp.new(path)
48
+ @@reference_path_re = Regexp.new(reference_path);
49
+
50
+ def self.is_path?(s)
51
+ s.is_a?(String) && @@path_re.match(s)
52
+ end
53
+
54
+ def self.is_reference_path?(s)
55
+ s.is_a?(String) && @@reference_path_re.match(s)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,233 @@
1
+ # Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may
4
+ # not use this file except in compliance with the License. A copy of the
5
+ # License is located at
6
+ #
7
+ # http://aws.amazon.com/apache2.0/
8
+ #
9
+ # or in the LICENSE.txt file accompanying this file. This file is distributed
10
+ # on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11
+ # express or implied. See the License for the specific language governing
12
+ # permissions and limitations under the License.
13
+
14
+ module J2119
15
+
16
+ # Does the heavy lifting of parsing j2119 files to extract all
17
+ # the assertions, making egregious use of regular expressions.
18
+ #
19
+ # This is the kind of thing I actively discourage when other
20
+ # programmers suggest it. If I were a real grown-up I'd
21
+ # implement a proper lexer and bullet-proof parser.
22
+ #
23
+ class Matcher
24
+
25
+ # crutch for RE debugging
26
+ attr_reader :constraint_match, :roledef_match, :only_one_match
27
+
28
+ # actual exports
29
+ attr_reader :role_matcher, :eachof_match
30
+
31
+ MUST = '(?<modal>MUST|MAY|MUST NOT)'
32
+ TYPES = [
33
+ 'array',
34
+ 'object',
35
+ 'string',
36
+ 'boolean',
37
+ 'numeric',
38
+ 'integer',
39
+ 'float',
40
+ 'timestamp',
41
+ 'JSONPath',
42
+ 'referencePath',
43
+ 'URI'
44
+ ]
45
+
46
+ RELATIONS = [
47
+ '', 'equal to', 'greater than', 'less than',
48
+ 'greater than or equal to', 'less than or equal to'
49
+ ].join('|')
50
+ RELATION = "((?<relation>#{RELATIONS})\\s+)"
51
+
52
+ S = '"[^"]*"' # string
53
+ V = '\S+' # non-string value: number, true, false, null
54
+ RELATIONAL = "#{RELATION}(?<target>#{S}|#{V})"
55
+
56
+ CHILD_ROLE = ';\s+((its\s+(?<child_type>value))|' +
57
+ 'each\s+(?<child_type>field|element))' +
58
+ '\s+is\s+an?\s+' +
59
+ '"(?<child_role>[^"]+)"'
60
+
61
+ @@initialized = false
62
+
63
+ # constants that need help from oxford
64
+ def constants
65
+ if !@@initialized
66
+ @@initialized = true
67
+
68
+ @@strings = Oxford.re(S, :capture_name => 'strings')
69
+ enum = "one\s+of\s+#{@@strings}"
70
+
71
+ @@predicate = "(#{RELATIONAL}|#{enum})"
72
+ end
73
+ end
74
+
75
+ def reconstruct
76
+ make_type_regex
77
+
78
+ # conditional clause
79
+ excluded_roles = "not\\s+" +
80
+ Oxford.re(@role_matcher,
81
+ :capture_name => 'excluded',
82
+ :use_article => true) +
83
+ "\\s+"
84
+ conditional = "which\\s+is\\s+" +
85
+ excluded_roles
86
+
87
+ # regex for matching constraint lines
88
+ c_start = '^An?\s+' +
89
+ "(?<role>#{@role_matcher})" + '\s+' +
90
+ "(#{conditional})?" +
91
+ MUST + '\s+have\s+an?\s+'
92
+ field_list = "one\\s+of\\s+" +
93
+ Oxford.re('"[^"]+"', :capture_name => 'field_list')
94
+ c_match = c_start +
95
+ "((?<type>#{@type_regex})\\s+)?" +
96
+ "field\\s+named\\s+" +
97
+ "((\"(?<field_name>[^\"]+)\")|(#{field_list}))" +
98
+ '(\s+whose\s+value\s+MUST\s+be\s+' + @@predicate + ')?' +
99
+ '(' + CHILD_ROLE + ')?' +
100
+ '\.'
101
+
102
+ # regexp for matching lines of the form
103
+ # "An X MUST have only one of "Y", "Z", and "W".
104
+ # There's a pattern here, building a separate regex rather than
105
+ # adding more complexity to @constraint_matcher. Any further
106
+ # additions should be done this way, and
107
+ # TODO: Break @constraint_matcher into a bunch of smaller patterns
108
+ # like this.
109
+ oo_start = '^An?\s+' +
110
+ "(?<role>#{@role_matcher})" + '\s+' +
111
+ MUST + '\s+have\s+only\s+'
112
+ oo_field_list = "one\\s+of\\s+" +
113
+ Oxford.re('"[^"]+"',
114
+ :capture_name => 'field_list',
115
+ :connector => 'and')
116
+ oo_match = oo_start + oo_field_list
117
+
118
+ # regex for matching role-def lines
119
+ val_match = "whose\\s+\"(?<fieldtomatch>[^\"]+)\"" +
120
+ "\\s+field's\\s+value\\s+is\\s+" +
121
+ "(?<valtomatch>(\"[^\"]*\")|([^\"\\s]\\S+))\\s+"
122
+ with_a_match = "with\\s+an?\\s+\"(?<with_a_field>[^\"]+)\"\\s+field\\s"
123
+
124
+ rd_match = '^An?\s+' +
125
+ "(?<role>#{@role_matcher})" + '\s+' +
126
+ "((?<val_match_present>#{val_match})|(#{with_a_match}))?" +
127
+ "is\\s+an?\\s+" +
128
+ "\"(?<newrole>[^\"]*)\"\\.\\s*$"
129
+ @roledef_match = Regexp.new(rd_match)
130
+
131
+ @constraint_start = Regexp.new(c_start)
132
+ @constraint_match = Regexp.new(c_match)
133
+
134
+ @only_one_start = Regexp.new(oo_start)
135
+ @only_one_match = Regexp.new(oo_match)
136
+
137
+ eo_match = "^Each\\s+of\\s" +
138
+ Oxford.re(@role_matcher,
139
+ :capture_name => 'each_of',
140
+ :use_article => true,
141
+ :connector => 'and') +
142
+ "\\s+(?<trailer>.*)$"
143
+
144
+ @eachof_match = Regexp.new(eo_match)
145
+ end
146
+
147
+ def initialize(root)
148
+ constants
149
+ @roles = []
150
+ add_role root
151
+ reconstruct
152
+ end
153
+
154
+ def add_role(role)
155
+ @roles << role
156
+ @role_matcher = @roles.join('|')
157
+ reconstruct
158
+ end
159
+
160
+ def self.tokenize_strings(s)
161
+ # should be a way to do this with capture groups but I'm not smart enough
162
+ strings = []
163
+ r = Regexp.new '^[^"]*"([^"]*)"'
164
+ while s =~ r
165
+ strings << $1
166
+ s = $'
167
+ end
168
+ strings
169
+ end
170
+
171
+ def tokenize_values(vals)
172
+ vals.gsub(',', ' ').gsub('or', ' ').split(/\s+/)
173
+ end
174
+
175
+ def make_type_regex
176
+
177
+ # add modified numeric types
178
+ types = TYPES.clone
179
+ number_types = [ 'float', 'integer', 'numeric' ]
180
+ number_modifiers = [ 'positive', 'negative', 'nonnegative' ]
181
+ number_types.each do |number_type|
182
+ number_modifiers.each do |number_modifier|
183
+ types << "#{number_modifier}-#{number_type}"
184
+ end
185
+ end
186
+
187
+ # add array types
188
+ array_types = types.map { |t| "#{t}-array" }
189
+ types |= array_types
190
+ nonempty_array_types = array_types.map { |t| "nonempty-#{t}" }
191
+ types |= nonempty_array_types
192
+ nullable_types = types.map { |t| "nullable-#{t}" }
193
+ types |= nullable_types
194
+ @type_regex = types.join('|')
195
+ end
196
+
197
+ def is_role_def_line(line)
198
+ line =~ %r{is\s+an?\s+"[^"]*"\.\s*$}
199
+ end
200
+
201
+ def build_role_def(line)
202
+ build(@roledef_match, line)
203
+ end
204
+
205
+ def build(re, line)
206
+ data = {}
207
+ match = re.match(line)
208
+ match.names.each do |name|
209
+ data[name] = match[name]
210
+ end
211
+ data
212
+ end
213
+
214
+ def build_only_one(line)
215
+ build(@only_one_match, line)
216
+ end
217
+
218
+ def is_constraint_line(line)
219
+ line =~ @constraint_start
220
+ end
221
+
222
+ def is_only_one_match_line(line)
223
+ line =~ @only_one_start
224
+ end
225
+
226
+ def build_constraint(line)
227
+ build(@constraint_match, line)
228
+ end
229
+
230
+ end
231
+
232
+
233
+ end