Module: JSONSchemaUtils

Defined in:
common/json_schema_utils.rb

Constant Summary collapse

SCHEMA_PARSE_RULES =
[
 {
   :failed_attribute => ['Properties', 'IfMissing', 'ArchivesSpaceSubType'],
   :pattern => /([A-Z]+: )?The property '.*?' did not contain a required property of '(.*?)'.*/,
   :do => ->(msgs, message, path, type, property) {
     if type && type =~ /ERROR/
       msgs[:errors][fragment_join(path, property)] = ["Property is required but was missing"]
     else
       msgs[:warnings][fragment_join(path, property)] = ["Property was missing"]
     end
   }
 },

 {
   :failed_attribute => ['ArchivesSpaceType'],
   :pattern => /The property '#(.*?)' was not a well-formed date/,
   :do => ->(msgs, message, path, property) {
     msgs[:errors][fragment_join(path)] = ["Not a valid date"]
   }
 },

 {
   :failed_attribute => ['Pattern'],
   :pattern => /The property '#\/.*?' did not match the regex '(.*?)' in schema/,
   :do => ->(msgs, message, path, regexp) {
     msgs[:errors][fragment_join(path)] = ["Did not match regular expression: #{regexp}"]
   }
 },

 {
   :failed_attribute => ['MinLength'],
   :pattern => /The property '#\/.*?' was not of a minimum string length of ([0-9]+) in schema/,
   :do => ->(msgs, message, path, length) {
     msgs[:errors][fragment_join(path)] = ["Must be at least #{length} characters"]
   }
 },

 {
   :failed_attribute => ['MaxLength'],
   :pattern => /The property '#\/.*?' was not of a maximum string length of ([0-9]+) in schema/,
   :do => ->(msgs, message, path, length) {
     msgs[:errors][fragment_join(path)] = ["Must be #{length} characters or fewer"]
   }
 },

 {
   :failed_attribute => ['MinItems'],
   :pattern => /The property '#\/.*?' did not contain a minimum number of items ([0-9]+) in schema/,
   :do => ->(msgs, message, path, items) {
     msgs[:errors][fragment_join(path)] = ["At least #{items} item(s) is required"]
   }
 },

 {
   :failed_attribute => ['Enum'],
   :pattern => /The property '#\/.*?' value "(.*?)" .*values: (.*) in schema/,
   :do => ->(msgs, message, path, invalid, valid_set) {
     msgs[:errors][fragment_join(path)] = ["Invalid value '#{invalid}'.  Must be one of: #{valid_set}"]
   }
 },

 {
   :failed_attribute => ['ArchivesSpaceDynamicEnum'],
   :pattern => /The property '#\/.*?' value "(.*?)" .*values: (.*) in schema/,
   :do => ->(msgs, message, path, invalid, valid_set) {
     msgs[:attribute_types][fragment_join(path)] = 'ArchivesSpaceDynamicEnum'
     msgs[:errors][fragment_join(path)] = ["Invalid value '#{invalid}'.  Must be one of: #{valid_set}"]
   }
 },
 {
   :failed_attribute => ['ArchivesSpaceReadOnlyDynamicEnum'],
   :pattern => /The property '#\/.*?' value "(.*?)" .*values: (.*) in schema/,
   :do => ->(msgs, message, path, invalid, valid_set) {
     msgs[:attribute_types][fragment_join(path)] = 'ArchivesSpaceReadOnlyDynamicEnum'
     msgs[:errors][fragment_join(path)] = ["Protected read-only list #{path}. Invalid value '#{invalid}'.  Must be one of: #{valid_set}"]
   }
 },

 {
   :failed_attribute => ['Type', 'ArchivesSpaceType'],
   :pattern => /The property '#\/.*?' of type (.*?) did not match the following type: (.*?) in schema/,
   :do => ->(msgs, message, path, actual_type, desired_type) {
     if actual_type !~ /JSONModel/ || message[:failed_attribute] == 'ArchivesSpaceType'
       # We'll skip JSONModels because the specific problem with the
       # document will have already been listed separately.

       msgs[:state][fragment_join(path)] ||= []
       msgs[:state][fragment_join(path)] << desired_type

       if msgs[:state][fragment_join(path)].length == 1
         msgs[:errors][fragment_join(path)] = ["Must be a #{desired_type} (you provided a #{actual_type})"]
         # a little better messages for malformed uri
         if desired_type =~ /uri$/
           msgs[:errors][fragment_join(path)].first << " (malformed or invalid uri? check if referenced object exists.)"
         end
       else
         msgs[:errors][fragment_join(path)] = ["Must be one of: #{msgs[:state][fragment_join(path)].join (", ")} (you provided a #{actual_type})"]
       end
     end

   }
 },

 {
   :failed_attribute => ['custom_validation'],
   :pattern => /Validation failed for '(.*?)': (.*?) in schema /,
   :do => ->(msgs, message, path, property, msg) {
     property = (property && !property.empty?) ? property : nil
     msgs[:errors][fragment_join(path, property)] = [msg]
   }
 },

 {
   :failed_attribute => ['custom_validation'],
   :pattern => /Warning generated for '(.*?)': (.*?) in schema /,
   :do => ->(msgs, message, path, property, msg) {
     msgs[:warnings][fragment_join(path, property)] = [msg]
   }
 },

 {
   :failed_attribute => ['custom_validation'],
   :pattern => /Validation error code: (.*?) in schema /,
   :do => ->(msgs, message, path, error_code) {
     msgs[:errors]['coded_errors'] = [error_code]
   }
 },


 # Catch all
 {
   :failed_attribute => nil,
   :pattern => /^(.*)$/,
   :do => ->(msgs, message, path, msg) {
     msgs[:errors]['unknown'] = [msg]
   }
 }
]

Class Method Summary collapse

Class Method Details

.apply_schema_defaults(hash, schema) ⇒ Object



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'common/json_schema_utils.rb', line 380

def self.apply_schema_defaults(hash, schema)
  fn = proc do |hash, schema|
    result = hash.clone

    schema["properties"].each do |property, definition|

      if definition.has_key?("default") && !hash.has_key?(property.to_s) && !hash.has_key?(property.intern)
        result[property] = definition["default"]
      elsif definition['type'] == 'array' && !hash.has_key?(property.to_s) && !hash.has_key?(property.intern)
        # Array values that weren't provided default to empty
        result[property] = []
      end

    end

    result
  end

  map_hash_with_schema(hash, schema, [fn])
end

.blank?(obj) ⇒ Boolean

Returns:

  • (Boolean)


314
315
316
# File 'common/json_schema_utils.rb', line 314

def self.blank?(obj)
  obj.nil? || obj == '' || obj == {}
end

.drop_empty_elements(obj) ⇒ Object

Recursively walk a map and remove any empty strings, empty maps and nils. Recursively collapses elements so that if, for example, an map becomes empty after having its own empty elements removed, it gets removed as well.



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'common/json_schema_utils.rb', line 321

def self.drop_empty_elements(obj)
  queue = [obj]
  to_visit = []

  while !queue.empty?
    obj = queue.shift

    if obj.is_a?(Hash) || obj.is_a?(Array)
      (obj.is_a?(Hash) ? obj.values : obj).each do |v|
        if v.is_a?(Hash) || v.is_a?(Array)
          queue.push(v)
        end
      end
    end

    to_visit.unshift(obj)
  end

  while !to_visit.empty?
    obj = to_visit.shift

    if obj.is_a?(Array)
      obj.reject! {|elt| blank?(elt)}
    elsif obj.is_a?(Hash)
      obj.keys.each do |k|
        if blank?(obj[k])
          obj.delete(k)
        end
      end
    end
  end

  obj
end

.drop_unknown_properties(hash, schema, drop_readonly = false) ⇒ Object

Drop any keys from ‘hash’ that aren’t defined in the JSON schema.

If drop_readonly is true, also drop any values where the schema has ‘readonly’ set to true. These values are produced by the system for the client, but are not part of the data model.



362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'common/json_schema_utils.rb', line 362

def self.drop_unknown_properties(hash, schema, drop_readonly = false)
  fn = proc do |hash, schema|
    result = {}

    hash.each do |k, v|
      if schema["properties"].has_key?(k.to_s) && (!drop_readonly || !schema["properties"][k.to_s]["readonly"])
        result[k] = v
      end
    end

    result
  end

  hash = drop_empty_elements(hash)
  map_hash_with_schema(hash, schema, [fn])
end

.extract_suberrors(errors) ⇒ Object

For a given error, find its list of sub errors.



179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'common/json_schema_utils.rb', line 179

def self.extract_suberrors(errors)
  errors = Array[errors].flatten

  result = errors.map do |error|
    if !error[:errors]
      error
    else
      self.extract_suberrors(error[:errors])
    end
  end

  result.flatten
end

.fragment_join(fragment, property = nil) ⇒ Object



3
4
5
6
7
8
9
10
11
12
# File 'common/json_schema_utils.rb', line 3

def self.fragment_join(fragment, property = nil)
  fragment = fragment.gsub(/^#\//, "")
  property = property.gsub(/^#\//, "") if property

  if property && fragment != "" && fragment !~ /\/$/
    fragment = "#{fragment}/"
  end

  "#{fragment}#{property}"
end

.map_hash_with_schema(record, schema, transformations = []) ⇒ Object

Given a hash representing a record tree, map across the hash and this model’s schema in lockstep.

Each proc in the ‘transformations’ array is called with the current node in the record tree as its first argument, and the part of the schema that corresponds to it. Whatever the proc returns is used to replace the node in the record tree.



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'common/json_schema_utils.rb', line 240

def self.map_hash_with_schema(record, schema, transformations = [])
  return record if not record.is_a?(Hash)

  if schema.is_a?(String)
    schema = resolve_schema_reference(schema)
  end

  # Sometimes a schema won't specify anything other than the required type
  # (like {'type' => 'object'}).  If there's nothing more to check, we're
  # done.
  return record if !schema.has_key?("properties")


  # Apply transformations to the current level of the tree
  transformations.each do |transform|
    record = transform.call(record, schema)
  end

  # Now figure out how to traverse the remainder of the tree...
  result = {}

  record.each do |k, v|
    k = k.to_s
    properties = schema['properties']

    if properties.has_key?(k) && (properties[k]["type"] == "object")
      result[k] = self.map_hash_with_schema(v, properties[k], transformations)

    elsif v.is_a?(Array) && properties.has_key?(k) && (properties[k]["type"] == "array")

      # Arrays are tricky because they can either consist of a single type, or
      # a number of different types.

      if properties[k]["items"]["type"].is_a?(Array)
        result[k] = v.map {|elt|

          if elt.is_a?(Hash)
            next_schema = determine_schema_for(elt, properties[k]["items"]["type"])
            self.map_hash_with_schema(elt, next_schema, transformations)
          elsif elt.is_a?(Array)
            raise "Nested arrays aren't supported here (yet)"
          else
            elt
          end
        }

      # The array contains a single type of object
      elsif properties[k]["items"]["type"] === "object"
        result[k] = v.map {|elt| self.map_hash_with_schema(elt, properties[k]["items"], transformations)}
      else
        # Just one valid type
        result[k] = v.map {|elt| self.map_hash_with_schema(elt, properties[k]["items"]["type"], transformations)}
      end

    elsif (v.is_a?(Hash) || v.is_a?(Array)) && (properties.has_key?(k) && properties[k]["type"].is_a?(Array))
      # Multiple possible types for this single value

      results = (v.is_a?(Array) ? v : [v]).map {|elt|
        next_schema = determine_schema_for(elt, properties[k]["type"])
        self.map_hash_with_schema(elt, next_schema, transformations)
      }

      result[k] = v.is_a?(Array) ? results : results[0]

    elsif properties.has_key?(k) && JSONModel.parse_jsonmodel_ref(properties[k]["type"])
      result[k] = self.map_hash_with_schema(v, properties[k]["type"], transformations)
    else
      result[k] = v
    end
  end

  result
end

.parse_schema_messages(messages, validator) ⇒ Object

Given a list of error messages produced by JSON schema validation, parse them into a structured format like:

{ :errors => => “(What was wrong with attr1)”, :warnings => => “(attr2 not quite right either)” }



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'common/json_schema_utils.rb', line 201

def self.parse_schema_messages(messages, validator)
  messages = self.extract_suberrors(messages)

  msgs = {
    :errors => {},
    :warnings => {},
    # to lookup e.g., msgs[:attribute_types]['extents/0/extent_type'] => 'ArchivesSpaceDynamicEnum'
    :attribute_types => {},
    :state => {}              # give the parse rules somewhere to store useful state for a run
  }

  messages.each do |message|

    SCHEMA_PARSE_RULES.each do |rule|
      if (rule[:failed_attribute].nil? || rule[:failed_attribute].include?(message[:failed_attribute])) and
          message[:message] =~ rule[:pattern]

        rule[:do].call(msgs, message, message[:fragment],
                       *message[:message].scan(rule[:pattern]).flatten)

        break
      end
    end

  end

  msgs.delete(:state)
  msgs
end

.schema_path_lookup(schema, path) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'common/json_schema_utils.rb', line 15

def self.schema_path_lookup(schema, path)
  if path.is_a? String
    return self.schema_path_lookup(schema, path.split("/"))
  end

  if schema.has_key?('properties')
    schema = schema['properties']
  end

  if path.length == 1
    schema[path.first]
  else
    if schema[path.first]
      self.schema_path_lookup(schema[path.first], path.drop(1))
    else
      nil
    end
  end
end