j2119 0.1.0

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