class Lacerda::Compare::JsonSchema

Constants

ERRORS

Attributes

errors[R]

Public Class Methods

new(containing_schema) click to toggle source
# File lib/lacerda/compare/json_schema.rb, line 18
def initialize(containing_schema)
  @containing_schema = containing_schema
  @errors = []
end

Public Instance Methods

contains?(contained_schema, initial_location = nil) click to toggle source
# File lib/lacerda/compare/json_schema.rb, line 23
def contains?(contained_schema, initial_location = nil)
  @errors = []
  @initial_location = initial_location
  @contained_schema = contained_schema
  properties_contained?
end
schema_contains?(options) click to toggle source
# File lib/lacerda/compare/json_schema.rb, line 30
def schema_contains?(options)
  publish      = options[:publish]
  consume      = options[:consume]
  location     = options[:location] || []
  return false unless publish and consume

  # We can only compare types and $refs, so let's make
  # sure they're there
  return _e(:ERR_MISSING_TYPE_AND_REF_AND_ONE_OF, location) unless
    (consume['type'] or consume['$ref'] or consume['oneOf']) and
    (publish['type'] or publish['$ref'] or publish['oneOf'])

  # There's four possibilities here:
  #
  # 1) publish and consume have a type defined
  # 2) publish and consume have a $ref defined
  # 3) publish has a $ref defined, and consume an inline object
  # 4) consume has a $ref defined, and publish an inline object
  #    (we don't support this yet, as otherwise couldn't check for
  #    missing definitions, because we could never know if something
  #    specified in the definitions of the consuming schema exists in
  #    the publishing schema as an inline property somewhere).
  #    TODO: check if what I just said makes sense. I'm not sure anymore.
  # Let's go:

  # 1)
  if consume['type'] and publish['type']
    consume_types = ([consume['type']].flatten).sort
    publish_types = [publish['type']].flatten.sort
    if !(publish_types - consume_types).blank?
      return _e(:ERR_TYPE_MISMATCH, location, "Consume types #{consume_types.to_json} not compatible with publish types #{publish_types.to_json}")
    end

  # 2)
  elsif consume['$ref'] and publish['$ref']
   resolved_consume = resolve_pointer(consume['$ref'], @contained_schema)
   resolved_publish = resolve_pointer(publish['$ref'], @containing_schema)

   return _e(:ERR_MISSING_POINTER, location, consume['$ref']) unless resolved_consume
   return _e(:ERR_MISSING_POINTER, location, publish['$ref']) unless resolved_publish
   return schema_contains?(publish: resolved_publish, consume: resolved_consume, location: location)

  # 3)
  elsif consume['type'] and publish['$ref']
    if resolved_ref = resolve_pointer(publish['$ref'], @containing_schema)
      return schema_contains?(publish: resolved_ref, consume: consume, location: location)
    else
      return _e(:ERR_MISSING_POINTER, location, publish['$ref'])
    end

  # 4)
  elsif consume['$ref'] and publish['type']
    return _e(:ERR_NOT_SUPPORTED, location, nil)
  end

  # Make sure required properties in consume are required in publish
  consume_required = consume['required'] || []
  publish_required = publish['required'] || []
  missing = (consume_required - publish_required)
  return _e(:ERR_MISSING_REQUIRED, location, missing.to_json) unless missing.empty?

  # We already know that publish and consume's type are equal
  # but if they're objects, we need to do some recursion
  isnt_a_primitive  = [consume['type']].flatten.include?('object') || consume['oneOf'] || publish['oneOf']
  if isnt_a_primitive

    # An object can either be described by its properties
    # like this:
    #
    # (1) { "type": "object", "properties": { "active": { "type": "boolean" } }
    #
    # or by allowing a bunch of other types like this:
    #
    # (2) { "type": "object", "oneOf": [ {"$ref": "#/definitions/foo"}, {"type": "null"} ]
    #
    # So we need to take care of both cases for both "sides"
    # (publish and consume), so 4 cases in total.
    #
    # First, the easy case:
    if consume['properties'] and publish['properties']
      consume['properties'].each do |property, schema|
        return _e(:ERR_MISSING_PROPERTY, location, property) unless publish['properties'][property]
        return false unless schema_contains?(publish: publish['properties'][property], consume: schema, location: location + [property])
      end

    # Now on to the trickier case, both have 'oneOf's:
    #
    # For each possible object type from the publish schema we have
    # to check if we find a compatible type in the consume schema.
    #
    # It's not sufficient to just compare the names of the objects,
    # because they might be different in the publish and consume
    # schemas.
    elsif publish['oneOf'] and consume['oneOf']
      publish_types = publish['oneOf']
      consume_types = [consume['oneOf']].flatten.compact

      # Check all publish types for a compatible consume type
      publish_types.each do |publish_type|
        errors = []
        consume_types.any? do |consume_type|
         errors = compare_sub_types(publish_type, consume_type, location + [publish_type])
         errors.empty?
        end
        if errors.any?
          # As there is only one type in each oneOf, we can give more specific error.
          # TODO: add this to other cases
          if publish_types.size == 1 && consume_types.size == 1
            @errors.push(*errors)
          else
            _e(:ERR_MISSING_MULTI_PUBLISH_MULTI_CONSUME, location, publish_type)
          end
          return false
        end
      end

    # Mixed case 1/2:
    elsif consume['oneOf'] and publish['properties']
      consume_types = ([consume['oneOf']].flatten - [{"type" => "null"}]).sort
      compatible_consume_type_found = false
      original_errors = @errors
      @errors = []
      consume_types.each do |consume_type|
        next unless schema_contains?(publish: publish, consume: consume_type, location: location)
        compatible_consume_type_found = true
      end
      @errors = original_errors
      unless compatible_consume_type_found
        return _e(:ERR_MISSING_SINGLE_PUBLISH_MULTI_CONSUME, location, publish['type'])
      end

    # Mixed case 2/2:
    elsif consume['properties'] and publish['oneOf']
      publish_types = ([publish['oneOf']].flatten - [{"type" => "null"}]).sort
      incompatible_publish_type= nil
      original_errors = @errors
      @errors = []
      publish_types.each do |publish_type|
        next if schema_contains?(publish: publish_type, consume: consume, location: location)
        incompatible_publish_type = publish_type
      end
      @errors = original_errors
      if incompatible_publish_type
        return _e(:ERR_MISSING_MULTI_PUBLISH_SINGLE_CONSUME, location, incompatible_publish_type)
      end

    # We don't know how to handle this 😳
    # an object can either have "properties" or "oneOf", if the schema has anything else, we break
    else
      return _e(:ERR_NOT_SUPPORTED, location, "Consume schema didn't have properties defined and publish schema no oneOf")
    end
  end

  if consume['type'] == 'array' && publish['type'] == 'array'
    if !consume['items'].is_a?(Hash) || !publish['items'].is_a?(Hash)
      return _e(:ERR_NOT_IMPLEMENTED, location, "'items' can only be hash (schema)")
    elsif !schema_contains?(publish: publish['items'], consume: consume['items'])
      return _e(:ERR_ARRAY_ITEM_MISMATCH, location, nil)
    end
  end
  true
end

Private Instance Methods

_e(error, location, extra = nil) click to toggle source
# File lib/lacerda/compare/json_schema.rb, line 229
def _e(error, location, extra = nil)
  message = [ERRORS[error], extra].compact.join(": ")
  @errors.push(error: error, message: message, location: location.compact.join("/"))
  false
end
compare_sub_types(containing, contained, location) click to toggle source

If you just want to compare two json objects/types, this method wraps them into full schemas, creates a new instance of self and compares

# File lib/lacerda/compare/json_schema.rb, line 278
def compare_sub_types(containing, contained, location)

  resolved_containing = data_for_pointer(containing, @containing_schema)
  resolved_contained  = data_for_pointer(contained,  @contained_schema)

  containing_schema = {
    'definitions' => { 'foo' => resolved_containing},
    'properties' => { 'bar' => { '$ref' => '#/definitions/foo' } }
  }
  comparator = self.class.new(containing_schema)
  comparator.schema_contains?(publish: resolved_containing, consume: resolved_contained)
  comparator.errors
end
data_for_pointer(data_or_pointer, schema) click to toggle source

Resolve pointer data idempotent(ally?). It will resolve

"foobar"

or

{ "$ref": "#/definitions/foobar" }

or

{ "type": "whatever", ... }

to

{ "type" :"whatever", ... }
# File lib/lacerda/compare/json_schema.rb, line 251
def data_for_pointer(data_or_pointer, schema)
  data = nil
  if data_or_pointer['type'] || data_or_pointer['oneOf']
    data = data_or_pointer
  elsif pointer = data_or_pointer['$ref']
    data = resolve_pointer(pointer, schema)
  else
    data = schema['definitions'][data_or_pointer]
  end
  data
end
properties_contained?() click to toggle source
# File lib/lacerda/compare/json_schema.rb, line 195
def properties_contained?
  # success is used to ensure we have no errors.
  success = @contained_schema['properties'].map do |name, content|
    property_contained?(name, content)
  end.all? {|is_property_contained| is_property_contained }
  success && @errors.empty?
end
property_contained?(property_name, content) click to toggle source
# File lib/lacerda/compare/json_schema.rb, line 203
def property_contained?(property_name, content)
  resolved_contained_property = data_for_pointer(content, @contained_schema)
  containing_property = @containing_schema['properties'][property_name]

  if !containing_property
    return _e(:ERR_MISSING_DEFINITION, [@initial_location, property_name], "(in publish.mson)")
  end

  # Make sure required properties in consume are required in publish
  publish_required = @containing_schema['required'] || []
  consume_required = @contained_schema['required'] || []
  missing = (consume_required - publish_required)
  return _e(:ERR_MISSING_REQUIRED, [property_name], missing.to_json) unless missing.empty?

  resolved_containing_property = data_for_pointer(
    containing_property,
    @containing_schema
  )

  schema_contains?(
    publish: resolved_containing_property,
    consume: resolved_contained_property,
    location: [property_name]
  )
end
resolve_pointer(pointer, schema) click to toggle source

Looks up a pointer like #/definitions/foobar and return its definition

# File lib/lacerda/compare/json_schema.rb, line 265
def resolve_pointer(pointer, schema)
  type = pointer[/\#\/definitions\/([^\/]+)$/, 1]
  return false unless type
  # TODO: Not so sure if we should raise an error
  # when schema['definitions'] is missing?
  return false unless schema
  return false unless schema['definitions']
  schema['definitions'][type]
end