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 48import os.path 49import re 50import sys 51 52def list_to_map(items, key): 53 result = {} 54 for item in items: 55 if not "hidden" in item: 56 result[item[key]] = item 57 return result 58 59def named_list_to_map(container, name, key): 60 if name in container: 61 return list_to_map(container[name], key) 62 return {} 63 64def removed(reverse): 65 if reverse: 66 return "added" 67 return "removed" 68 69def required(reverse): 70 if reverse: 71 return "optional" 72 return "required" 73 74def compare_schemas(schema_1, schema_2, reverse): 75 errors = [] 76 types_1 = normalize_types_in_schema(schema_1) 77 types_2 = normalize_types_in_schema(schema_2) 78 79 domains_by_name_1 = list_to_map(schema_1, "domain") 80 domains_by_name_2 = list_to_map(schema_2, "domain") 81 82 for name in domains_by_name_1: 83 domain_1 = domains_by_name_1[name] 84 if not name in domains_by_name_2: 85 errors.append("%s: domain has been %s" % (name, removed(reverse))) 86 continue 87 compare_domains(domain_1, domains_by_name_2[name], types_1, types_2, errors, reverse) 88 return errors 89 90def compare_domains(domain_1, domain_2, types_map_1, types_map_2, errors, reverse): 91 domain_name = domain_1["domain"] 92 commands_1 = named_list_to_map(domain_1, "commands", "name") 93 commands_2 = named_list_to_map(domain_2, "commands", "name") 94 for name in commands_1: 95 command_1 = commands_1[name] 96 if not name in commands_2: 97 errors.append("%s.%s: command has been %s" % (domain_1["domain"], name, removed(reverse))) 98 continue 99 compare_commands(domain_name, command_1, commands_2[name], types_map_1, types_map_2, errors, reverse) 100 101 events_1 = named_list_to_map(domain_1, "events", "name") 102 events_2 = named_list_to_map(domain_2, "events", "name") 103 for name in events_1: 104 event_1 = events_1[name] 105 if not name in events_2: 106 errors.append("%s.%s: event has been %s" % (domain_1["domain"], name, removed(reverse))) 107 continue 108 compare_events(domain_name, event_1, events_2[name], types_map_1, types_map_2, errors, reverse) 109 110def compare_commands(domain_name, command_1, command_2, types_map_1, types_map_2, errors, reverse): 111 context = domain_name + "." + command_1["name"] 112 113 params_1 = named_list_to_map(command_1, "parameters", "name") 114 params_2 = named_list_to_map(command_2, "parameters", "name") 115 # Note the reversed order: we allow removing but forbid adding parameters. 116 compare_params_list(context, "parameter", params_2, params_1, types_map_2, types_map_1, 0, errors, not reverse) 117 118 returns_1 = named_list_to_map(command_1, "returns", "name") 119 returns_2 = named_list_to_map(command_2, "returns", "name") 120 compare_params_list(context, "response parameter", returns_1, returns_2, types_map_1, types_map_2, 0, errors, reverse) 121 122def compare_events(domain_name, event_1, event_2, types_map_1, types_map_2, errors, reverse): 123 context = domain_name + "." + event_1["name"] 124 params_1 = named_list_to_map(event_1, "parameters", "name") 125 params_2 = named_list_to_map(event_2, "parameters", "name") 126 compare_params_list(context, "parameter", params_1, params_2, types_map_1, types_map_2, 0, errors, reverse) 127 128def compare_params_list(context, kind, params_1, params_2, types_map_1, types_map_2, depth, errors, reverse): 129 for name in params_1: 130 param_1 = params_1[name] 131 if not name in params_2: 132 if not "optional" in param_1: 133 errors.append("%s.%s: required %s has been %s" % (context, name, kind, removed(reverse))) 134 continue 135 136 param_2 = params_2[name] 137 if param_2 and "optional" in param_2 and not "optional" in param_1: 138 errors.append("%s.%s: %s %s is now %s" % (context, name, required(reverse), kind, required(not reverse))) 139 continue 140 type_1 = extract_type(param_1, types_map_1, errors) 141 type_2 = extract_type(param_2, types_map_2, errors) 142 compare_types(context + "." + name, kind, type_1, type_2, types_map_1, types_map_2, depth, errors, reverse) 143 144def compare_types(context, kind, type_1, type_2, types_map_1, types_map_2, depth, errors, reverse): 145 if depth > 10: 146 return 147 148 base_type_1 = type_1["type"] 149 base_type_2 = type_2["type"] 150 151 if base_type_1 != base_type_2: 152 errors.append("%s: %s base type mismatch, '%s' vs '%s'" % (context, kind, base_type_1, base_type_2)) 153 elif base_type_1 == "object": 154 params_1 = named_list_to_map(type_1, "properties", "name") 155 params_2 = named_list_to_map(type_2, "properties", "name") 156 # If both parameters have the same named type use it in the context. 157 if "id" in type_1 and "id" in type_2 and type_1["id"] == type_2["id"]: 158 type_name = type_1["id"] 159 else: 160 type_name = "<object>" 161 context += " %s->%s" % (kind, type_name) 162 compare_params_list(context, "property", params_1, params_2, types_map_1, types_map_2, depth + 1, errors, reverse) 163 elif base_type_1 == "array": 164 item_type_1 = extract_type(type_1["items"], types_map_1, errors) 165 item_type_2 = extract_type(type_2["items"], types_map_2, errors) 166 compare_types(context, kind, item_type_1, item_type_2, types_map_1, types_map_2, depth + 1, errors, reverse) 167 168def extract_type(typed_object, types_map, errors): 169 if "type" in typed_object: 170 result = { "id": "<transient>", "type": typed_object["type"] } 171 if typed_object["type"] == "object": 172 result["properties"] = [] 173 elif typed_object["type"] == "array": 174 result["items"] = typed_object["items"] 175 return result 176 elif "$ref" in typed_object: 177 ref = typed_object["$ref"] 178 if not ref in types_map: 179 errors.append("Can not resolve type: %s" % ref) 180 types_map[ref] = { "id": "<transient>", "type": "object" } 181 return types_map[ref] 182 183def normalize_types_in_schema(schema): 184 types = {} 185 for domain in schema: 186 domain_name = domain["domain"] 187 normalize_types(domain, domain_name, types) 188 return types 189 190def normalize_types(obj, domain_name, types): 191 if isinstance(obj, list): 192 for item in obj: 193 normalize_types(item, domain_name, types) 194 elif isinstance(obj, dict): 195 for key, value in obj.items(): 196 if key == "$ref" and value.find(".") == -1: 197 obj[key] = "%s.%s" % (domain_name, value) 198 elif key == "id": 199 obj[key] = "%s.%s" % (domain_name, value) 200 types[obj[key]] = obj 201 else: 202 normalize_types(value, domain_name, types) 203 204def load_json(filename): 205 input_file = open(filename, "r") 206 json_string = input_file.read() 207 json_string = re.sub(":\s*true", ": True", json_string) 208 json_string = re.sub(":\s*false", ": False", json_string) 209 return eval(json_string) 210 211def self_test(): 212 def create_test_schema_1(): 213 return [ 214 { 215 "domain": "Network", 216 "types": [ 217 { 218 "id": "LoaderId", 219 "type": "string" 220 }, 221 { 222 "id": "Headers", 223 "type": "object" 224 }, 225 { 226 "id": "Request", 227 "type": "object", 228 "properties": [ 229 { "name": "url", "type": "string" }, 230 { "name": "method", "type": "string" }, 231 { "name": "headers", "$ref": "Headers" }, 232 { "name": "becameOptionalField", "type": "string" }, 233 { "name": "removedField", "type": "string" }, 234 ] 235 } 236 ], 237 "commands": [ 238 { 239 "name": "removedCommand", 240 }, 241 { 242 "name": "setExtraHTTPHeaders", 243 "parameters": [ 244 { "name": "headers", "$ref": "Headers" }, 245 { "name": "mismatched", "type": "string" }, 246 { "name": "becameOptional", "$ref": "Headers" }, 247 { "name": "removedRequired", "$ref": "Headers" }, 248 { "name": "becameRequired", "$ref": "Headers", "optional": True }, 249 { "name": "removedOptional", "$ref": "Headers", "optional": True }, 250 ], 251 "returns": [ 252 { "name": "mimeType", "type": "string" }, 253 { "name": "becameOptional", "type": "string" }, 254 { "name": "removedRequired", "type": "string" }, 255 { "name": "becameRequired", "type": "string", "optional": True }, 256 { "name": "removedOptional", "type": "string", "optional": True }, 257 ] 258 } 259 ], 260 "events": [ 261 { 262 "name": "requestWillBeSent", 263 "parameters": [ 264 { "name": "frameId", "type": "string", "hidden": True }, 265 { "name": "request", "$ref": "Request" }, 266 { "name": "becameOptional", "type": "string" }, 267 { "name": "removedRequired", "type": "string" }, 268 { "name": "becameRequired", "type": "string", "optional": True }, 269 { "name": "removedOptional", "type": "string", "optional": True }, 270 ] 271 }, 272 { 273 "name": "removedEvent", 274 "parameters": [ 275 { "name": "errorText", "type": "string" }, 276 { "name": "canceled", "type": "boolean", "optional": True } 277 ] 278 } 279 ] 280 }, 281 { 282 "domain": "removedDomain" 283 } 284 ] 285 286 def create_test_schema_2(): 287 return [ 288 { 289 "domain": "Network", 290 "types": [ 291 { 292 "id": "LoaderId", 293 "type": "string" 294 }, 295 { 296 "id": "Request", 297 "type": "object", 298 "properties": [ 299 { "name": "url", "type": "string" }, 300 { "name": "method", "type": "string" }, 301 { "name": "headers", "type": "object" }, 302 { "name": "becameOptionalField", "type": "string", "optional": True }, 303 ] 304 } 305 ], 306 "commands": [ 307 { 308 "name": "addedCommand", 309 }, 310 { 311 "name": "setExtraHTTPHeaders", 312 "parameters": [ 313 { "name": "headers", "type": "object" }, 314 { "name": "mismatched", "type": "object" }, 315 { "name": "becameOptional", "type": "object" , "optional": True }, 316 { "name": "addedRequired", "type": "object" }, 317 { "name": "becameRequired", "type": "object" }, 318 { "name": "addedOptional", "type": "object", "optional": True }, 319 ], 320 "returns": [ 321 { "name": "mimeType", "type": "string" }, 322 { "name": "becameOptional", "type": "string", "optional": True }, 323 { "name": "addedRequired", "type": "string"}, 324 { "name": "becameRequired", "type": "string" }, 325 { "name": "addedOptional", "type": "string", "optional": True }, 326 ] 327 } 328 ], 329 "events": [ 330 { 331 "name": "requestWillBeSent", 332 "parameters": [ 333 { "name": "request", "$ref": "Request" }, 334 { "name": "becameOptional", "type": "string", "optional": True }, 335 { "name": "addedRequired", "type": "string"}, 336 { "name": "becameRequired", "type": "string" }, 337 { "name": "addedOptional", "type": "string", "optional": True }, 338 ] 339 }, 340 { 341 "name": "addedEvent" 342 } 343 ] 344 }, 345 { 346 "domain": "addedDomain" 347 } 348 ] 349 350 expected_errors = [ 351 "removedDomain: domain has been removed", 352 "Network.removedCommand: command has been removed", 353 "Network.removedEvent: event has been removed", 354 "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 'object' vs 'string'", 355 "Network.setExtraHTTPHeaders.addedRequired: required parameter has been added", 356 "Network.setExtraHTTPHeaders.becameRequired: optional parameter is now required", 357 "Network.setExtraHTTPHeaders.removedRequired: required response parameter has been removed", 358 "Network.setExtraHTTPHeaders.becameOptional: required response parameter is now optional", 359 "Network.requestWillBeSent.removedRequired: required parameter has been removed", 360 "Network.requestWillBeSent.becameOptional: required parameter is now optional", 361 "Network.requestWillBeSent.request parameter->Network.Request.removedField: required property has been removed", 362 "Network.requestWillBeSent.request parameter->Network.Request.becameOptionalField: required property is now optional", 363 ] 364 365 expected_errors_reverse = [ 366 "addedDomain: domain has been added", 367 "Network.addedEvent: event has been added", 368 "Network.addedCommand: command has been added", 369 "Network.setExtraHTTPHeaders.mismatched: parameter base type mismatch, 'string' vs 'object'", 370 "Network.setExtraHTTPHeaders.removedRequired: required parameter has been removed", 371 "Network.setExtraHTTPHeaders.becameOptional: required parameter is now optional", 372 "Network.setExtraHTTPHeaders.addedRequired: required response parameter has been added", 373 "Network.setExtraHTTPHeaders.becameRequired: optional response parameter is now required", 374 "Network.requestWillBeSent.becameRequired: optional parameter is now required", 375 "Network.requestWillBeSent.addedRequired: required parameter has been added", 376 ] 377 378 def is_subset(subset, superset, message): 379 for i in range(len(subset)): 380 if subset[i] not in superset: 381 sys.stderr.write("%s error: %s\n" % (message, subset[i])) 382 return False 383 return True 384 385 def errors_match(expected, actual): 386 return (is_subset(actual, expected, "Unexpected") and 387 is_subset(expected, actual, "Missing")) 388 389 return (errors_match(expected_errors, 390 compare_schemas(create_test_schema_1(), create_test_schema_2(), False)) and 391 errors_match(expected_errors_reverse, 392 compare_schemas(create_test_schema_2(), create_test_schema_1(), True))) 393 394 395def main(): 396 if not self_test(): 397 sys.stderr.write("Self-test failed") 398 return 1 399 400 if len(sys.argv) < 4 or sys.argv[1] != "-o": 401 sys.stderr.write("Usage: %s -o OUTPUT_FILE INPUT_FILE [--show-changes]\n" % sys.argv[0]) 402 return 1 403 404 output_path = sys.argv[2] 405 output_file = open(output_path, "w") 406 407 input_path = sys.argv[3] 408 dir_name = os.path.dirname(input_path) 409 schema = load_json(input_path) 410 411 major = schema["version"]["major"] 412 minor = schema["version"]["minor"] 413 version = "%s.%s" % (major, minor) 414 if len(dir_name) == 0: 415 dir_name = "." 416 baseline_path = os.path.normpath(dir_name + "/Inspector-" + version + ".json") 417 baseline_schema = load_json(baseline_path) 418 419 errors = compare_schemas(baseline_schema["domains"], schema["domains"], False) 420 if len(errors) > 0: 421 sys.stderr.write(" Compatibility with %s: FAILED\n" % version) 422 for error in errors: 423 sys.stderr.write( " %s\n" % error) 424 return 1 425 426 if len(sys.argv) > 4 and sys.argv[4] == "--show-changes": 427 changes = compare_schemas( 428 load_json(input_path)["domains"], load_json(baseline_path)["domains"], True) 429 if len(changes) > 0: 430 print " Public changes since %s:" % version 431 for change in changes: 432 print " %s" % change 433 434 output_file.write(""" 435#ifndef InspectorProtocolVersion_h 436#define InspectorProtocolVersion_h 437 438#include "wtf/Vector.h" 439#include "wtf/text/WTFString.h" 440 441namespace WebCore { 442 443String inspectorProtocolVersion() { return "%s"; } 444 445int inspectorProtocolVersionMajor() { return %s; } 446 447int inspectorProtocolVersionMinor() { return %s; } 448 449bool supportsInspectorProtocolVersion(const String& version) 450{ 451 Vector<String> tokens; 452 version.split(".", tokens); 453 if (tokens.size() != 2) 454 return false; 455 456 bool ok = true; 457 int major = tokens[0].toInt(&ok); 458 if (!ok || major != %s) 459 return false; 460 461 int minor = tokens[1].toInt(&ok); 462 if (!ok || minor > %s) 463 return false; 464 465 return true; 466} 467 468} 469 470#endif // !defined(InspectorProtocolVersion_h) 471""" % (version, major, minor, major, minor)) 472 473 output_file.close() 474 475if __name__ == '__main__': 476 sys.exit(main()) 477