| #!/usr/bin/env python | |
| # Copyright (c) 2011 Google Inc. All rights reserved. | |
| # | |
| # Redistribution and use in source and binary forms, with or without | |
| # modification, are permitted provided that the following conditions are | |
| # met: | |
| # | |
| # * Redistributions of source code must retain the above copyright | |
| # notice, this list of conditions and the following disclaimer. | |
| # * Redistributions in binary form must reproduce the above | |
| # copyright notice, this list of conditions and the following disclaimer | |
| # in the documentation and/or other materials provided with the | |
| # distribution. | |
| # * Neither the name of Google Inc. nor the names of its | |
| # contributors may be used to endorse or promote products derived from | |
| # this software without specific prior written permission. | |
| # | |
| # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | |
| # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT | |
| # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | |
| # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT | |
| # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, | |
| # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT | |
| # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, | |
| # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY | |
| # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | |
| # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| # | |
| # Inspector protocol validator. | |
| # | |
| # Tests that subsequent protocol changes are not breaking backwards compatibility. | |
| # Following violations are reported: | |
| # | |
| # - Domain has been removed | |
| # - Command has been removed | |
| # - Required command parameter was added or changed from optional | |
| # - Required response parameter was removed or changed to optional | |
| # - Event has been removed | |
| # - Required event parameter was removed or changed to optional | |
| # - Parameter type has changed. | |
| # | |
| # For the parameters with composite types the above checks are also applied | |
| # recursively to every property of the type. | |
| # | |
| # Adding --show_changes to the command line prints out a list of valid public API changes. | |
| import copy | |
| import os.path | |
| import optparse | |
| import sys | |
| try: | |
| import json | |
| except ImportError: | |
| import simplejson as json | |
| def list_to_map(items, key): | |
| result = {} | |
| for item in items: | |
| if "experimental" not in item and "hidden" not in item: | |
| result[item[key]] = item | |
| return result | |
| def named_list_to_map(container, name, key): | |
| if name in container: | |
| return list_to_map(container[name], key) | |
| return {} | |
| def removed(reverse): | |
| if reverse: | |
| return "added" | |
| return "removed" | |
| def required(reverse): | |
| if reverse: | |
| return "optional" | |
| return "required" | |
| def compare_schemas(d_1, d_2, reverse): | |
| errors = [] | |
| domains_1 = copy.deepcopy(d_1) | |
| domains_2 = copy.deepcopy(d_2) | |
| types_1 = normalize_types_in_schema(domains_1) | |
| types_2 = normalize_types_in_schema(domains_2) | |
| domains_by_name_1 = list_to_map(domains_1, "domain") | |
| domains_by_name_2 = list_to_map(domains_2, "domain") | |
| for name in domains_by_name_1: | |
| domain_1 = domains_by_name_1[name] | |
| if name not in domains_by_name_2: | |
| errors.append("%s: domain has been %s" % (name, removed(reverse))) | |
| continue | |
| compare_domains(domain_1, domains_by_name_2[name], types_1, types_2, errors, reverse) | |
| return errors | |
| def compare_domains(domain_1, domain_2, types_map_1, types_map_2, errors, reverse): | |
| domain_name = domain_1["domain"] | |
| commands_1 = named_list_to_map(domain_1, "commands", "name") | |
| commands_2 = named_list_to_map(domain_2, "commands", "name") | |
| for name in commands_1: | |
| command_1 = commands_1[name] | |
| if name not in commands_2: | |
| errors.append("%s.%s: command has been %s" % (domain_1["domain"], name, removed(reverse))) | |
| continue | |
| compare_commands(domain_name, command_1, commands_2[name], types_map_1, types_map_2, errors, reverse) | |
| events_1 = named_list_to_map(domain_1, "events", "name") | |
| events_2 = named_list_to_map(domain_2, "events", "name") | |
| for name in events_1: | |
| event_1 = events_1[name] | |
| if name not in events_2: | |
| errors.append("%s.%s: event has been %s" % (domain_1["domain"], name, removed(reverse))) | |
| continue | |
| compare_events(domain_name, event_1, events_2[name], types_map_1, types_map_2, errors, reverse) | |
| def compare_commands(domain_name, command_1, command_2, types_map_1, types_map_2, errors, reverse): | |
| context = domain_name + "." + command_1["name"] | |
| params_1 = named_list_to_map(command_1, "parameters", "name") | |
| params_2 = named_list_to_map(command_2, "parameters", "name") | |
| # Note the reversed order: we allow removing but forbid adding parameters. | |
| compare_params_list(context, "parameter", params_2, params_1, types_map_2, types_map_1, 0, errors, not reverse) | |
| returns_1 = named_list_to_map(command_1, "returns", "name") | |
| returns_2 = named_list_to_map(command_2, "returns", "name") | |
| compare_params_list(context, "response parameter", returns_1, returns_2, types_map_1, types_map_2, 0, errors, reverse) | |
| def compare_events(domain_name, event_1, event_2, types_map_1, types_map_2, errors, reverse): | |
| context = domain_name + "." + event_1["name"] | |
| params_1 = named_list_to_map(event_1, "parameters", "name") | |
| params_2 = named_list_to_map(event_2, "parameters", "name") | |
| compare_params_list(context, "parameter", params_1, params_2, types_map_1, types_map_2, 0, errors, reverse) | |
| def compare_params_list(context, kind, params_1, params_2, types_map_1, types_map_2, depth, errors, reverse): | |
| for name in params_1: | |
| param_1 = params_1[name] | |
| if name not in params_2: | |
| if "optional" not in param_1: | |
| errors.append("%s.%s: required %s has been %s" % (context, name, kind, removed(reverse))) | |
| continue | |
| param_2 = params_2[name] | |
| if param_2 and "optional" in param_2 and "optional" not in param_1: | |
| errors.append("%s.%s: %s %s is now %s" % (context, name, required(reverse), kind, required(not reverse))) | |
| continue | |
| type_1 = extract_type(param_1, types_map_1, errors) | |
| type_2 = extract_type(param_2, types_map_2, errors) | |
| compare_types(context + "." + name, kind, type_1, type_2, types_map_1, types_map_2, depth, errors, reverse) | |
| def compare_types(context, kind, type_1, type_2, types_map_1, types_map_2, depth, errors, reverse): | |
| if depth > 5: | |
| return | |
| base_type_1 = type_1["type"] | |
| base_type_2 = type_2["type"] | |
| if base_type_1 != base_type_2: | |
| errors.append("%s: %s base type mismatch, '%s' vs '%s'" % (context, kind, base_type_1, base_type_2)) | |
| elif base_type_1 == "object": | |
| params_1 = named_list_to_map(type_1, "properties", "name") | |
| params_2 = named_list_to_map(type_2, "properties", "name") | |
| # If both parameters have the same named type use it in the context. | |
| if "id" in type_1 and "id" in type_2 and type_1["id"] == type_2["id"]: | |
| type_name = type_1["id"] | |
| else: | |
| type_name = "<object>" | |
| context += " %s->%s" % (kind, type_name) | |
| compare_params_list(context, "property", params_1, params_2, types_map_1, types_map_2, depth + 1, errors, reverse) | |
| elif base_type_1 == "array": | |
| item_type_1 = extract_type(type_1["items"], types_map_1, errors) | |
| item_type_2 = extract_type(type_2["items"], types_map_2, errors) | |
| compare_types(context, kind, item_type_1, item_type_2, types_map_1, types_map_2, depth + 1, errors, reverse) | |
| def extract_type(typed_object, types_map, errors): | |
| if "type" in typed_object: | |
| result = {"id": "<transient>", "type": typed_object["type"]} | |
| if typed_object["type"] == "object": | |
| result["properties"] = [] | |
| elif typed_object["type"] == "array": | |
| result["items"] = typed_object["items"] | |
| return result | |
| elif "$ref" in typed_object: | |
| ref = typed_object["$ref"] | |
| if ref not in types_map: | |
| errors.append("Can not resolve type: %s" % ref) | |
| types_map[ref] = {"id": "<transient>", "type": "object"} | |
| return types_map[ref] | |
| def normalize_types_in_schema(domains): | |
| types = {} | |
| for domain in domains: | |
| domain_name = domain["domain"] | |
| normalize_types(domain, domain_name, types) | |
| return types | |
| def normalize_types(obj, domain_name, types): | |
| if isinstance(obj, list): | |
| for item in obj: | |
| normalize_types(item, domain_name, types) | |
| elif isinstance(obj, dict): | |
| for key, value in obj.items(): | |
| if key == "$ref" and value.find(".") == -1: | |
| obj[key] = "%s.%s" % (domain_name, value) | |
| elif key == "id": | |
| obj[key] = "%s.%s" % (domain_name, value) | |
| types[obj[key]] = obj | |
| else: | |
| normalize_types(value, domain_name, types) | |
| def load_schema(file_name, domains): | |
| # pylint: disable=W0613 | |
| if not os.path.isfile(file_name): | |
| return | |
| input_file = open(file_name, "r") | |
| json_string = input_file.read() | |
| parsed_json = json.loads(json_string) | |
| domains += parsed_json["domains"] | |
| return parsed_json["version"] | |
| def self_test(): | |
| def create_test_schema_1(): | |
| return [ | |
| { | |
| "domain": "Network", | |
| "types": [ | |
| { | |
| "id": "LoaderId", | |
| "type": "string" | |
| }, | |
| { | |
| "id": "Headers", | |
| "type": "object" | |
| }, | |
| { | |
| "id": "Request", | |
| "type": "object", | |
| "properties": [ | |
| {"name": "url", "type": "string"}, | |
| {"name": "method", "type": "string"}, | |
| {"name": "headers", "$ref": "Headers"}, | |
| {"name": "becameOptionalField", "type": "string"}, | |
| {"name": "removedField", "type": "string"}, | |
| ] | |
| } | |
| ], | |
| "commands": [ | |
| { | |
| "name": "removedCommand", | |
| }, | |
| { | |
| "name": "setExtraHTTPHeaders", | |
| "parameters": [ | |
| {"name": "headers", "$ref": "Headers"}, | |
| {"name": "mismatched", "type": "string"}, | |
| {"name": "becameOptional", "$ref": "Headers"}, | |
| {"name": "removedRequired", "$ref": "Headers"}, | |
| {"name": "becameRequired", "$ref": "Headers", "optional": True}, | |
| {"name": "removedOptional", "$ref": "Headers", "optional": True}, | |
| ], | |
| "returns": [ | |
| {"name": "mimeType", "type": "string"}, | |
| {"name": "becameOptional", "type": "string"}, | |
| {"name": "removedRequired", "type": "string"}, | |
| {"name": "becameRequired", "type": "string", "optional": True}, | |
| {"name": "removedOptional", "type": "string", "optional": True}, | |
| ] | |
| } | |
| ], | |
| "events": [ | |
| { | |
| "name": "requestWillBeSent", | |
| "parameters": [ | |
| {"name": "frameId", "type": "string", "experimental": True}, | |
| {"name": "request", "$ref": "Request"}, | |
| {"name": "becameOptional", "type": "string"}, | |
| {"name": "removedRequired", "type": "string"}, | |
| {"name": "becameRequired", "type": "string", "optional": True}, | |
| {"name": "removedOptional", "type": "string", "optional": True}, | |
| ] | |
| }, | |
| { | |
| "name": "removedEvent", | |
| "parameters": [ | |
| {"name": "errorText", "type": "string"}, | |
| {"name": "canceled", "type": "boolean", "optional": True} | |
| ] | |
| } | |
| ] | |
| }, | |
| { | |
| "domain": "removedDomain" | |
| } | |
| ] | |
| def create_test_schema_2(): | |
| return [ | |
| { | |
| "domain": "Network", | |
| "types": [ | |
| { | |
| "id": "LoaderId", | |
| "type": "string" | |
| }, | |
| { | |
| "id": "Request", | |
| "type": "object", | |
| "properties": [ | |
| {"name": "url", "type": "string"}, | |
| {"name": "method", "type": "string"}, | |
| {"name": "headers", "type": "object"}, | |
| {"name": "becameOptionalField", "type": "string", "optional": True}, | |
| ] | |
| } | |
| ], | |
| "commands": [ | |
| { | |
| "name": "addedCommand", | |
| }, | |
| { | |
| "name": "setExtraHTTPHeaders", | |
| "parameters": [ | |
| {"name": "headers", "type": "object"}, | |
| {"name": "mismatched", "type": "object"}, | |
| {"name": "becameOptional", "type": "object", "optional": True}, | |
| {"name": "addedRequired", "type": "object"}, | |
| {"name": "becameRequired", "type": "object"}, | |
| {"name": "addedOptional", "type": "object", "optional": True}, | |
| ], | |
| "returns": [ | |
| {"name": "mimeType", "type": "string"}, | |
| {"name": "becameOptional", "type": "string", "optional": True}, | |
| {"name": "addedRequired", "type": "string"}, | |
| {"name": "becameRequired", "type": "string"}, | |
| {"name": "addedOptional", "type": "string", "optional": True}, | |
| ] | |
| } | |
| ], | |
| "events": [ | |
| { | |
| "name": "requestWillBeSent", | |
| "parameters": [ | |
| {"name": "request", "$ref": "Request"}, | |
| {"name": "becameOptional", "type": "string", "optional": True}, | |
| {"name": "addedRequired", "type": "string"}, | |
| {"name": "becameRequired", "type": "string"}, | |
| {"name": "addedOptional", "type": "string", "optional": True}, | |
| ] | |
| }, | |
| { | |
| "name": "addedEvent" | |
| } | |
| ] | |
| }, | |
| { | |
| "domain": "addedDomain" | |
| } | |
| ] | |
| expected_errors = [ | |
| "removedDomain: domain has been removed", | |
| "Network.removedCommand: command has been removed", | |
| "Network.removedEvent: event has been removed", | |
| "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 'object' vs 'string'", | |
| "Network.setExtraHTTPHeaders.addedRequired: required parameter has been added", | |
| "Network.setExtraHTTPHeaders.becameRequired: optional parameter is now required", | |
| "Network.setExtraHTTPHeaders.removedRequired: required response parameter has been removed", | |
| "Network.setExtraHTTPHeaders.becameOptional: required response parameter is now optional", | |
| "Network.requestWillBeSent.removedRequired: required parameter has been removed", | |
| "Network.requestWillBeSent.becameOptional: required parameter is now optional", | |
| "Network.requestWillBeSent.request parameter->Network.Request.removedField: required property has been removed", | |
| "Network.requestWillBeSent.request parameter->Network.Request.becameOptionalField: required property is now optional", | |
| ] | |
| expected_errors_reverse = [ | |
| "addedDomain: domain has been added", | |
| "Network.addedEvent: event has been added", | |
| "Network.addedCommand: command has been added", | |
| "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 'string' vs 'object'", | |
| "Network.setExtraHTTPHeaders.removedRequired: required parameter has been removed", | |
| "Network.setExtraHTTPHeaders.becameOptional: required parameter is now optional", | |
| "Network.setExtraHTTPHeaders.addedRequired: required response parameter has been added", | |
| "Network.setExtraHTTPHeaders.becameRequired: optional response parameter is now required", | |
| "Network.requestWillBeSent.becameRequired: optional parameter is now required", | |
| "Network.requestWillBeSent.addedRequired: required parameter has been added", | |
| ] | |
| def is_subset(subset, superset, message): | |
| for i in range(len(subset)): | |
| if subset[i] not in superset: | |
| sys.stderr.write("%s error: %s\n" % (message, subset[i])) | |
| return False | |
| return True | |
| def errors_match(expected, actual): | |
| return (is_subset(actual, expected, "Unexpected") and | |
| is_subset(expected, actual, "Missing")) | |
| return (errors_match(expected_errors, | |
| compare_schemas(create_test_schema_1(), create_test_schema_2(), False)) and | |
| errors_match(expected_errors_reverse, | |
| compare_schemas(create_test_schema_2(), create_test_schema_1(), True))) | |
| def load_domains_and_baselines(file_name, domains, baseline_domains): | |
| version = load_schema(os.path.normpath(file_name), domains) | |
| suffix = "-%s.%s.json" % (version["major"], version["minor"]) | |
| baseline_file = file_name.replace(".json", suffix) | |
| load_schema(os.path.normpath(baseline_file), baseline_domains) | |
| return version | |
| def main(): | |
| if not self_test(): | |
| sys.stderr.write("Self-test failed") | |
| return 1 | |
| cmdline_parser = optparse.OptionParser() | |
| cmdline_parser.add_option("--show_changes") | |
| cmdline_parser.add_option("--expected_errors") | |
| cmdline_parser.add_option("--stamp") | |
| arg_options, arg_values = cmdline_parser.parse_args() | |
| if len(arg_values) < 1: | |
| sys.stderr.write("Usage: %s [--show_changes] <protocol-1> [, <protocol-2>...]\n" % sys.argv[0]) | |
| return 1 | |
| domains = [] | |
| baseline_domains = [] | |
| version = load_domains_and_baselines(arg_values[0], domains, baseline_domains) | |
| for dependency in arg_values[1:]: | |
| load_domains_and_baselines(dependency, domains, baseline_domains) | |
| expected_errors = [] | |
| if arg_options.expected_errors: | |
| expected_errors_file = open(arg_options.expected_errors, "r") | |
| expected_errors = json.loads(expected_errors_file.read())["errors"] | |
| expected_errors_file.close() | |
| errors = compare_schemas(baseline_domains, domains, False) | |
| unexpected_errors = [] | |
| for i in range(len(errors)): | |
| if errors[i] not in expected_errors: | |
| unexpected_errors.append(errors[i]) | |
| if len(unexpected_errors) > 0: | |
| sys.stderr.write(" Compatibility checks FAILED\n") | |
| for error in unexpected_errors: | |
| sys.stderr.write(" %s\n" % error) | |
| return 1 | |
| if arg_options.show_changes: | |
| changes = compare_schemas(domains, baseline_domains, True) | |
| if len(changes) > 0: | |
| print " Public changes since %s:" % version | |
| for change in changes: | |
| print " %s" % change | |
| if arg_options.stamp: | |
| with open(arg_options.stamp, 'a') as _: | |
| pass | |
| if __name__ == '__main__': | |
| sys.exit(main()) |