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