blob: d6ee227267c3e3730ff34c0c12e6b524a0d441f2 [file] [log] [blame]
#!/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 os.path
import re
import sys
def list_to_map(items, key):
result = {}
for item in items:
if not "hidden" 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(schema_1, schema_2, reverse):
errors = []
types_1 = normalize_types_in_schema(schema_1)
types_2 = normalize_types_in_schema(schema_2)
domains_by_name_1 = list_to_map(schema_1, "domain")
domains_by_name_2 = list_to_map(schema_2, "domain")
for name in domains_by_name_1:
domain_1 = domains_by_name_1[name]
if not name 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 not name 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 not name 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 not name in params_2:
if not "optional" 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 not "optional" 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 > 10:
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 not ref 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(schema):
types = {}
for domain in schema:
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_json(filename):
input_file = open(filename, "r")
json_string = input_file.read()
json_string = re.sub(":\s*true", ": True", json_string)
json_string = re.sub(":\s*false", ": False", json_string)
return eval(json_string)
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", "hidden": 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 main():
if not self_test():
sys.stderr.write("Self-test failed")
return 1
if len(sys.argv) < 4 or sys.argv[1] != "-o":
sys.stderr.write("Usage: %s -o OUTPUT_FILE INPUT_FILE [--show-changes]\n" % sys.argv[0])
return 1
output_path = sys.argv[2]
output_file = open(output_path, "w")
input_path = sys.argv[3]
dir_name = os.path.dirname(input_path)
schema = load_json(input_path)
major = schema["version"]["major"]
minor = schema["version"]["minor"]
version = "%s.%s" % (major, minor)
if len(dir_name) == 0:
dir_name = "."
baseline_path = os.path.normpath(dir_name + "/Inspector-" + version + ".json")
baseline_schema = load_json(baseline_path)
errors = compare_schemas(baseline_schema["domains"], schema["domains"], False)
if len(errors) > 0:
sys.stderr.write(" Compatibility with %s: FAILED\n" % version)
for error in errors:
sys.stderr.write( " %s\n" % error)
return 1
if len(sys.argv) > 4 and sys.argv[4] == "--show-changes":
changes = compare_schemas(
load_json(input_path)["domains"], load_json(baseline_path)["domains"], True)
if len(changes) > 0:
print " Public changes since %s:" % version
for change in changes:
print " %s" % change
output_file.write("""
#ifndef InspectorProtocolVersion_h
#define InspectorProtocolVersion_h
#include "wtf/Vector.h"
#include "wtf/text/WTFString.h"
namespace blink {
String inspectorProtocolVersion() { return "%s"; }
int inspectorProtocolVersionMajor() { return %s; }
int inspectorProtocolVersionMinor() { return %s; }
bool supportsInspectorProtocolVersion(const String& version)
{
Vector<String> tokens;
version.split(".", tokens);
if (tokens.size() != 2)
return false;
bool ok = true;
int major = tokens[0].toInt(&ok);
if (!ok || major != %s)
return false;
int minor = tokens[1].toInt(&ok);
if (!ok || minor > %s)
return false;
return true;
}
}
#endif // !defined(InspectorProtocolVersion_h)
""" % (version, major, minor, major, minor))
output_file.close()
if __name__ == '__main__':
sys.exit(main())