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 blink {
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