Class: Lacerda::Compare::JsonSchema

Inherits:
Object
  • Object
show all
Defined in:
lib/lacerda/compare/json_schema.rb

Constant Summary collapse

ERRORS =
{
  :ERR_ARRAY_ITEM_MISMATCH  => "The items in the published array don't match the consumer's specification.",
  :ERR_MISSING_DEFINITION   => "The publish specification is missing a type defined in the consumer's specification.",
  :ERR_MISSING_POINTER      => "A JSON pointer could not be resolved.",
  :ERR_MISSING_PROPERTY     => "The published object is missing a property required by your specification.",
  :ERR_MISSING_REQUIRED     => "The published object has an optional property that you marked as required in your specification.",
  :ERR_MISSING_TYPE_AND_REF_AND_ONE_OF => 'A property has to either have a "type", "oneOf" or "$ref" property.',
  :ERR_TYPE_MISMATCH        => "The published object has a property with a different type than the consumer's specification.",
  :ERR_NOT_IMPLEMENTED      => "Not implemented.",
  :ERR_NOT_SUPPORTED        => 'I don\'t yet know what to do when the consumer\'s specification has a "$ref" defined and the publisher\'s specification has a "type".'
}

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(containing_schema) ⇒ JsonSchema

Returns a new instance of JsonSchema.



18
19
20
21
# File 'lib/lacerda/compare/json_schema.rb', line 18

def initialize(containing_schema)
  @containing_schema = containing_schema
  @errors = []
end

Instance Attribute Details

#errorsObject (readonly)

Returns the value of attribute errors.



16
17
18
# File 'lib/lacerda/compare/json_schema.rb', line 16

def errors
  @errors
end

Instance Method Details

#contains?(contained_schema, initial_location = nil) ⇒ Boolean

Returns:

  • (Boolean)


23
24
25
26
27
28
# 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) ⇒ Boolean

Returns:

  • (Boolean)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# 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