• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python
2# Copyright (c) 2011 Google Inc. All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8#     * Redistributions of source code must retain the above copyright
9# notice, this list of conditions and the following disclaimer.
10#     * Redistributions in binary form must reproduce the above
11# copyright notice, this list of conditions and the following disclaimer
12# in the documentation and/or other materials provided with the
13# distribution.
14#     * Neither the name of Google Inc. nor the names of its
15# contributors may be used to endorse or promote products derived from
16# this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29#
30# Inspector protocol validator.
31#
32# Tests that subsequent protocol changes are not breaking backwards compatibility.
33# Following violations are reported:
34#
35#   - Domain has been removed
36#   - Command has been removed
37#   - Required command parameter was added or changed from optional
38#   - Required response parameter was removed or changed to optional
39#   - Event has been removed
40#   - Required event parameter was removed or changed to optional
41#   - Parameter type has changed.
42#
43# For the parameters with composite types the above checks are also applied
44# recursively to every property of the type.
45#
46# Adding --show_changes to the command line prints out a list of valid public API changes.
47
48from __future__ import print_function
49import copy
50import os.path
51import optparse
52import sys
53
54import pdl
55
56try:
57    import json
58except ImportError:
59    import simplejson as json
60
61
62def list_to_map(items, key):
63    result = {}
64    for item in items:
65        if "experimental" not in item and "hidden" not in item:
66            result[item[key]] = item
67    return result
68
69
70def named_list_to_map(container, name, key):
71    if name in container:
72        return list_to_map(container[name], key)
73    return {}
74
75
76def removed(reverse):
77    if reverse:
78        return "added"
79    return "removed"
80
81
82def required(reverse):
83    if reverse:
84        return "optional"
85    return "required"
86
87
88def compare_schemas(d_1, d_2, reverse):
89    errors = []
90    domains_1 = copy.deepcopy(d_1)
91    domains_2 = copy.deepcopy(d_2)
92    types_1 = normalize_types_in_schema(domains_1)
93    types_2 = normalize_types_in_schema(domains_2)
94
95    domains_by_name_1 = list_to_map(domains_1, "domain")
96    domains_by_name_2 = list_to_map(domains_2, "domain")
97
98    for name in domains_by_name_1:
99        domain_1 = domains_by_name_1[name]
100        if name not in domains_by_name_2:
101            errors.append("%s: domain has been %s" % (name, removed(reverse)))
102            continue
103        compare_domains(domain_1, domains_by_name_2[name], types_1, types_2, errors, reverse)
104    return errors
105
106
107def compare_domains(domain_1, domain_2, types_map_1, types_map_2, errors, reverse):
108    domain_name = domain_1["domain"]
109    commands_1 = named_list_to_map(domain_1, "commands", "name")
110    commands_2 = named_list_to_map(domain_2, "commands", "name")
111    for name in commands_1:
112        command_1 = commands_1[name]
113        if name not in commands_2:
114            errors.append("%s.%s: command has been %s" % (domain_1["domain"], name, removed(reverse)))
115            continue
116        compare_commands(domain_name, command_1, commands_2[name], types_map_1, types_map_2, errors, reverse)
117
118    events_1 = named_list_to_map(domain_1, "events", "name")
119    events_2 = named_list_to_map(domain_2, "events", "name")
120    for name in events_1:
121        event_1 = events_1[name]
122        if name not in events_2:
123            errors.append("%s.%s: event has been %s" % (domain_1["domain"], name, removed(reverse)))
124            continue
125        compare_events(domain_name, event_1, events_2[name], types_map_1, types_map_2, errors, reverse)
126
127
128def compare_commands(domain_name, command_1, command_2, types_map_1, types_map_2, errors, reverse):
129    context = domain_name + "." + command_1["name"]
130
131    params_1 = named_list_to_map(command_1, "parameters", "name")
132    params_2 = named_list_to_map(command_2, "parameters", "name")
133    # Note the reversed order: we allow removing but forbid adding parameters.
134    compare_params_list(context, "parameter", params_2, params_1, types_map_2, types_map_1, 0, errors, not reverse)
135
136    returns_1 = named_list_to_map(command_1, "returns", "name")
137    returns_2 = named_list_to_map(command_2, "returns", "name")
138    compare_params_list(context, "response parameter", returns_1, returns_2, types_map_1, types_map_2, 0, errors, reverse)
139
140
141def compare_events(domain_name, event_1, event_2, types_map_1, types_map_2, errors, reverse):
142    context = domain_name + "." + event_1["name"]
143    params_1 = named_list_to_map(event_1, "parameters", "name")
144    params_2 = named_list_to_map(event_2, "parameters", "name")
145    compare_params_list(context, "parameter", params_1, params_2, types_map_1, types_map_2, 0, errors, reverse)
146
147
148def compare_params_list(context, kind, params_1, params_2, types_map_1, types_map_2, depth, errors, reverse):
149    for name in params_1:
150        param_1 = params_1[name]
151        if name not in params_2:
152            if "optional" not in param_1:
153                errors.append("%s.%s: required %s has been %s" % (context, name, kind, removed(reverse)))
154            continue
155
156        param_2 = params_2[name]
157        if param_2 and "optional" in param_2 and "optional" not in param_1:
158            errors.append("%s.%s: %s %s is now %s" % (context, name, required(reverse), kind, required(not reverse)))
159            continue
160        type_1 = extract_type(param_1, types_map_1, errors)
161        type_2 = extract_type(param_2, types_map_2, errors)
162        compare_types(context + "." + name, kind, type_1, type_2, types_map_1, types_map_2, depth, errors, reverse)
163
164
165def compare_types(context, kind, type_1, type_2, types_map_1, types_map_2, depth, errors, reverse):
166    if depth > 5:
167        return
168
169    base_type_1 = type_1["type"]
170    base_type_2 = type_2["type"]
171
172    # Binary and string have the same wire representation in JSON.
173    if ((base_type_1 == "string" and base_type_2 == "binary") or
174        (base_type_2 == "string" and base_type_1 == "binary")):
175      return
176
177    if base_type_1 != base_type_2:
178        errors.append("%s: %s base type mismatch, '%s' vs '%s'" % (context, kind, base_type_1, base_type_2))
179    elif base_type_1 == "object":
180        params_1 = named_list_to_map(type_1, "properties", "name")
181        params_2 = named_list_to_map(type_2, "properties", "name")
182        # If both parameters have the same named type use it in the context.
183        if "id" in type_1 and "id" in type_2 and type_1["id"] == type_2["id"]:
184            type_name = type_1["id"]
185        else:
186            type_name = "<object>"
187        context += " %s->%s" % (kind, type_name)
188        compare_params_list(context, "property", params_1, params_2, types_map_1, types_map_2, depth + 1, errors, reverse)
189    elif base_type_1 == "array":
190        item_type_1 = extract_type(type_1["items"], types_map_1, errors)
191        item_type_2 = extract_type(type_2["items"], types_map_2, errors)
192        compare_types(context, kind, item_type_1, item_type_2, types_map_1, types_map_2, depth + 1, errors, reverse)
193
194
195def extract_type(typed_object, types_map, errors):
196    if "type" in typed_object:
197        result = {"id": "<transient>", "type": typed_object["type"]}
198        if typed_object["type"] == "object":
199            result["properties"] = []
200        elif typed_object["type"] == "array":
201            result["items"] = typed_object["items"]
202        return result
203    elif "$ref" in typed_object:
204        ref = typed_object["$ref"]
205        if ref not in types_map:
206            errors.append("Can not resolve type: %s" % ref)
207            types_map[ref] = {"id": "<transient>", "type": "object"}
208        return types_map[ref]
209
210
211def normalize_types_in_schema(domains):
212    types = {}
213    for domain in domains:
214        domain_name = domain["domain"]
215        normalize_types(domain, domain_name, types)
216    return types
217
218
219def normalize_types(obj, domain_name, types):
220    if isinstance(obj, list):
221        for item in obj:
222            normalize_types(item, domain_name, types)
223    elif isinstance(obj, dict):
224        for key, value in obj.items():
225            if key == "$ref" and value.find(".") == -1:
226                obj[key] = "%s.%s" % (domain_name, value)
227            elif key == "id":
228                obj[key] = "%s.%s" % (domain_name, value)
229                types[obj[key]] = obj
230            else:
231                normalize_types(value, domain_name, types)
232
233
234def load_schema(file_name, domains):
235    # pylint: disable=W0613
236    if not os.path.isfile(file_name):
237        return
238    input_file = open(file_name, "r")
239    parsed_json = pdl.loads(input_file.read(), file_name)
240    input_file.close()
241    domains += parsed_json["domains"]
242    return parsed_json["version"]
243
244
245def self_test():
246    def create_test_schema_1():
247        return [
248            {
249                "domain": "Network",
250                "types": [
251                    {
252                        "id": "LoaderId",
253                        "type": "string"
254                    },
255                    {
256                        "id": "Headers",
257                        "type": "object"
258                    },
259                    {
260                        "id": "Request",
261                        "type": "object",
262                        "properties": [
263                            {"name": "url", "type": "string"},
264                            {"name": "method", "type": "string"},
265                            {"name": "headers", "$ref": "Headers"},
266                            {"name": "becameOptionalField", "type": "string"},
267                            {"name": "removedField", "type": "string"},
268                        ]
269                    }
270                ],
271                "commands": [
272                    {
273                        "name": "removedCommand",
274                    },
275                    {
276                        "name": "setExtraHTTPHeaders",
277                        "parameters": [
278                            {"name": "headers", "$ref": "Headers"},
279                            {"name": "mismatched", "type": "string"},
280                            {"name": "becameOptional", "$ref": "Headers"},
281                            {"name": "removedRequired", "$ref": "Headers"},
282                            {"name": "becameRequired", "$ref": "Headers", "optional": True},
283                            {"name": "removedOptional", "$ref": "Headers", "optional": True},
284                        ],
285                        "returns": [
286                            {"name": "mimeType", "type": "string"},
287                            {"name": "becameOptional", "type": "string"},
288                            {"name": "removedRequired", "type": "string"},
289                            {"name": "becameRequired", "type": "string", "optional": True},
290                            {"name": "removedOptional", "type": "string", "optional": True},
291                        ]
292                    }
293                ],
294                "events": [
295                    {
296                        "name": "requestWillBeSent",
297                        "parameters": [
298                            {"name": "frameId", "type": "string", "experimental": True},
299                            {"name": "request", "$ref": "Request"},
300                            {"name": "becameOptional", "type": "string"},
301                            {"name": "removedRequired", "type": "string"},
302                            {"name": "becameRequired", "type": "string", "optional": True},
303                            {"name": "removedOptional", "type": "string", "optional": True},
304                        ]
305                    },
306                    {
307                        "name": "removedEvent",
308                        "parameters": [
309                            {"name": "errorText", "type": "string"},
310                            {"name": "canceled", "type": "boolean", "optional": True}
311                        ]
312                    }
313                ]
314            },
315            {
316                "domain":  "removedDomain"
317            }
318        ]
319
320    def create_test_schema_2():
321        return [
322            {
323                "domain": "Network",
324                "types": [
325                    {
326                        "id": "LoaderId",
327                        "type": "string"
328                    },
329                    {
330                        "id": "Request",
331                        "type": "object",
332                        "properties": [
333                            {"name": "url", "type": "string"},
334                            {"name": "method", "type": "string"},
335                            {"name": "headers", "type": "object"},
336                            {"name": "becameOptionalField", "type": "string", "optional": True},
337                        ]
338                    }
339                ],
340                "commands": [
341                    {
342                        "name": "addedCommand",
343                    },
344                    {
345                        "name": "setExtraHTTPHeaders",
346                        "parameters": [
347                            {"name": "headers", "type": "object"},
348                            {"name": "mismatched", "type": "object"},
349                            {"name": "becameOptional", "type": "object", "optional": True},
350                            {"name": "addedRequired", "type": "object"},
351                            {"name": "becameRequired", "type": "object"},
352                            {"name": "addedOptional", "type": "object", "optional": True},
353                        ],
354                        "returns": [
355                            {"name": "mimeType", "type": "string"},
356                            {"name": "becameOptional", "type": "string", "optional": True},
357                            {"name": "addedRequired", "type": "string"},
358                            {"name": "becameRequired", "type": "string"},
359                            {"name": "addedOptional", "type": "string", "optional": True},
360                        ]
361                    }
362                ],
363                "events": [
364                    {
365                        "name": "requestWillBeSent",
366                        "parameters": [
367                            {"name": "request", "$ref": "Request"},
368                            {"name": "becameOptional", "type": "string", "optional": True},
369                            {"name": "addedRequired", "type": "string"},
370                            {"name": "becameRequired", "type": "string"},
371                            {"name": "addedOptional", "type": "string", "optional": True},
372                        ]
373                    },
374                    {
375                        "name": "addedEvent"
376                    }
377                ]
378            },
379            {
380                "domain": "addedDomain"
381            }
382        ]
383
384    expected_errors = [
385        "removedDomain: domain has been removed",
386        "Network.removedCommand: command has been removed",
387        "Network.removedEvent: event has been removed",
388        "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 'object' vs 'string'",
389        "Network.setExtraHTTPHeaders.addedRequired: required parameter has been added",
390        "Network.setExtraHTTPHeaders.becameRequired: optional parameter is now required",
391        "Network.setExtraHTTPHeaders.removedRequired: required response parameter has been removed",
392        "Network.setExtraHTTPHeaders.becameOptional: required response parameter is now optional",
393        "Network.requestWillBeSent.removedRequired: required parameter has been removed",
394        "Network.requestWillBeSent.becameOptional: required parameter is now optional",
395        "Network.requestWillBeSent.request parameter->Network.Request.removedField: required property has been removed",
396        "Network.requestWillBeSent.request parameter->Network.Request.becameOptionalField: required property is now optional",
397    ]
398
399    expected_errors_reverse = [
400        "addedDomain: domain has been added",
401        "Network.addedEvent: event has been added",
402        "Network.addedCommand: command has been added",
403        "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 'string' vs 'object'",
404        "Network.setExtraHTTPHeaders.removedRequired: required parameter has been removed",
405        "Network.setExtraHTTPHeaders.becameOptional: required parameter is now optional",
406        "Network.setExtraHTTPHeaders.addedRequired: required response parameter has been added",
407        "Network.setExtraHTTPHeaders.becameRequired: optional response parameter is now required",
408        "Network.requestWillBeSent.becameRequired: optional parameter is now required",
409        "Network.requestWillBeSent.addedRequired: required parameter has been added",
410    ]
411
412    def is_subset(subset, superset, message):
413        for i in range(len(subset)):
414            if subset[i] not in superset:
415                sys.stderr.write("%s error: %s\n" % (message, subset[i]))
416                return False
417        return True
418
419    def errors_match(expected, actual):
420        return (is_subset(actual, expected, "Unexpected") and
421                is_subset(expected, actual, "Missing"))
422
423    return (errors_match(expected_errors,
424                         compare_schemas(create_test_schema_1(), create_test_schema_2(), False)) and
425            errors_match(expected_errors_reverse,
426                         compare_schemas(create_test_schema_2(), create_test_schema_1(), True)))
427
428
429def load_domains_and_baselines(file_name, domains, baseline_domains):
430    version = load_schema(os.path.normpath(file_name), domains)
431    suffix = "-%s.%s.json" % (version["major"], version["minor"])
432    baseline_file = file_name.replace(".json", suffix)
433    baseline_file = file_name.replace(".pdl", suffix)
434    load_schema(os.path.normpath(baseline_file), baseline_domains)
435    return version
436
437
438def main():
439    if not self_test():
440        sys.stderr.write("Self-test failed")
441        return 1
442
443    cmdline_parser = optparse.OptionParser()
444    cmdline_parser.add_option("--show_changes")
445    cmdline_parser.add_option("--expected_errors")
446    cmdline_parser.add_option("--stamp")
447    arg_options, arg_values = cmdline_parser.parse_args()
448
449    if len(arg_values) < 1:
450        sys.stderr.write("Usage: %s [--show_changes] <protocol-1> [, <protocol-2>...]\n" % sys.argv[0])
451        return 1
452
453    domains = []
454    baseline_domains = []
455    version = load_domains_and_baselines(arg_values[0], domains, baseline_domains)
456    for dependency in arg_values[1:]:
457        load_domains_and_baselines(dependency, domains, baseline_domains)
458
459    expected_errors = []
460    if arg_options.expected_errors:
461        expected_errors_file = open(arg_options.expected_errors, "r")
462        expected_errors = json.loads(expected_errors_file.read())["errors"]
463        expected_errors_file.close()
464
465    errors = compare_schemas(baseline_domains, domains, False)
466    unexpected_errors = []
467    for i in range(len(errors)):
468        if errors[i] not in expected_errors:
469            unexpected_errors.append(errors[i])
470    if len(unexpected_errors) > 0:
471        sys.stderr.write("  Compatibility checks FAILED\n")
472        for error in unexpected_errors:
473            sys.stderr.write("    %s\n" % error)
474        return 1
475
476    if arg_options.show_changes:
477        changes = compare_schemas(domains, baseline_domains, True)
478        if len(changes) > 0:
479            print("  Public changes since %s:" % version)
480            for change in changes:
481                print("    %s" % change)
482
483    if arg_options.stamp:
484        with open(arg_options.stamp, 'a') as _:
485            pass
486
487if __name__ == '__main__':
488    sys.exit(main())
489