#!/usr/bin/env python3 # # Validates that 3 data sources agree about the structure of Zulip's events API: # # * Node fixtures for the server_events_dispatch.js tests. # * OpenAPI definitions in zerver/openapi/zulip.yaml # * The schemas defined in zerver/lib/events_schema.py used for the # Zulip server's test suite. # # We compare the Python and OpenAPI schemas by converting the OpenAPI data # into the event_schema style of types and the diffing the schemas. import argparse import difflib import os import subprocess import sys from collections.abc import Callable from typing import Any import orjson TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(TOOLS_DIR)) ROOT_DIR = os.path.dirname(TOOLS_DIR) EVENTS_JS = "web/tests/lib/events.cjs" # check for the venv from tools.lib import sanity_check sanity_check.check_venv(__file__) USAGE = """ This program reads in fixture data for our node tests, and then it validates the fixture data with checkers from event_schema.py (which are the same Python functions we use to validate events in test_events.py). It currently takes no arguments. """ parser = argparse.ArgumentParser(usage=USAGE) parser.parse_args() # We can eliminate the django dependency in event_schema, # but unfortunately it"s coupled to modules like validate.py # and topic.py. import django os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.test_settings" django.setup() from zerver.lib import event_schema from zerver.lib.data_types import ( DictType, EnumType, ListType, NumberType, StringDictType, UnionType, make_checker, schema, ) from zerver.openapi.openapi import openapi_spec # This list of exemptions represents details we should fix in Zulip's # API structure and/or validators. EXEMPT_OPENAPI_NAMES = [ # Additional keys(push_users_notify) due to bug in API. "message_event", # tuple handling "muted_topics_event", # bots, delivery_email, profile_data "realm_user_add_event", # OpenAPI is incomplete "realm_update_dict_event", # is_mirror_dummy "reaction_add_event", "reaction_remove_event", ] # This is a list of events still documented in the OpenAPI that # are deprecated and no longer checked in event_schema.py. DEPRECATED_EVENTS = [ "realm_filters_event", ] def get_event_checker(event: dict[str, Any]) -> Callable[[str, dict[str, Any]], None] | None: name = event["type"] if "op" in event: name += "_" + event["op"] name += "_event" if hasattr(event_schema, name): return make_checker(getattr(event_schema, name)) return None def check_event(name: str, event: dict[str, Any]) -> None: event["id"] = 1 checker = get_event_checker(event) if checker is not None: try: checker(name, event) except AssertionError: print(f"\n{EVENTS_JS} has bad data for {name}:\n\n") raise else: print(f"WARNING - NEED SCHEMA: {name}") def read_fixtures() -> dict[str, Any]: cmd = [ "node", os.path.join(TOOLS_DIR, "node_lib/dump_fixtures.js"), ] schema = subprocess.check_output(cmd) return orjson.loads(schema) def verify_fixtures_are_sorted(names: list[str]) -> None: for i in range(1, len(names)): if names[i] < names[i - 1]: raise Exception( f""" Please keep your fixtures in order within your events.js file. The following key is out of order {names[i]} """ ) def from_openapi(node: dict[str, Any]) -> Any: """Converts the OpenAPI data into event_schema.py style type definitions for convenient comparison with the types used for backend tests declared there.""" if "oneOf" in node: return UnionType([from_openapi(n) for n in node["oneOf"]]) if node["type"] == "object": if ( "additionalProperties" in node # this might be a glitch in our current spec? or # maybe I just understand it yet and isinstance(node["additionalProperties"], dict) ): return StringDictType(from_openapi(node["additionalProperties"])) if "properties" not in node: return dict required_keys = [] for key, sub_node in node["properties"].items(): required_keys.append((key, from_openapi(sub_node))) return DictType(required_keys) if node["type"] == "boolean": return bool if node["type"] == "integer": if "enum" in node: return EnumType(node["enum"]) return int if node["type"] == "number": return NumberType() if node["type"] == "string": if "enum" in node: return EnumType(node["enum"]) return str if node["type"] == "array": return ListType(from_openapi(node["items"])) raise AssertionError("cannot handle node") def validate_openapi_against_event_schema() -> None: node = openapi_spec.openapi()["paths"]["/events"]["get"]["responses"]["200"]["content"][ "application/json" ]["schema"]["properties"]["events"]["items"]["oneOf"] for sub_node in node: name = sub_node["properties"]["type"]["enum"][0] if "op" in sub_node["properties"]: name += "_" + sub_node["properties"]["op"]["enum"][0] name += "_event" if not hasattr(event_schema, name): if name not in DEPRECATED_EVENTS: print("WARNING - NEED SCHEMA to match OpenAPI", name) continue openapi_type = from_openapi(sub_node) openapi_schema = schema(name, openapi_type) py_type = getattr(event_schema, name) py_schema = schema(name, py_type) if name in EXEMPT_OPENAPI_NAMES: if openapi_schema == py_schema: raise AssertionError(f"unnecessary exemption for {name}") continue if openapi_schema != py_schema: print(f"py\n{py_schema}\n") print(f"openapi\n{openapi_schema}\n") for line in difflib.unified_diff( py_schema.split("\n"), openapi_schema.split("\n"), fromfile="py", tofile="openapi", ): print(line) raise AssertionError("openapi schemas disagree") def run() -> None: fixtures = read_fixtures() verify_fixtures_are_sorted(list(fixtures.keys())) for name, event in fixtures.items(): check_event(name, event) validate_openapi_against_event_schema() print("Successful check. All tests passed.") if __name__ == "__main__": run()