def ensure_no_documentation_if_intentionally_undocumented( self, url_pattern: str, method: str, msg: Optional[str] = None ) -> None: try: get_openapi_parameters(url_pattern, method) if not msg: # nocoverage msg = f""" We found some OpenAPI documentation for {method} {url_pattern}, so maybe we shouldn't mark it as intentionally undocumented in the URLs. """ raise AssertionError(msg) # nocoverage except KeyError: return
def run(self, lines: List[str]) -> List[str]: done = False while not done: for line in lines: loc = lines.index(line) match = REGEXP.search(line) if not match: continue filename = match.group(1) doc_name = match.group(2) filename = os.path.expanduser(filename) is_openapi_format = filename.endswith(".yaml") if not os.path.isabs(filename): parent_dir = self.base_path filename = os.path.normpath( os.path.join(parent_dir, filename)) if is_openapi_format: endpoint, method = doc_name.rsplit(":", 1) arguments: List[Dict[str, Any]] = [] try: arguments = get_openapi_parameters(endpoint, method) except KeyError as e: # Don't raise an exception if the "parameters" # field is missing; we assume that's because the # endpoint doesn't accept any parameters if e.args != ("parameters", ): raise e else: with open(filename) as fp: json_obj = json.load(fp) arguments = json_obj[doc_name] if arguments: text = self.render_table(arguments) # We want to show this message only if the parameters # description doesn't say anything else. elif is_openapi_format and get_parameters_description( endpoint, method) == "": text = ["This endpoint does not accept any parameters."] else: text = [] # The line that contains the directive to include the macro # may be preceded or followed by text or tags, in that case # we need to make sure that any preceding or following text # stays the same. line_split = REGEXP.split(line, maxsplit=0) preceding = line_split[0] following = line_split[-1] text = [preceding, *text, following] lines = lines[:loc] + text + lines[loc + 1:] break else: done = True return lines
def test_get_openapi_parameters(self) -> None: actual = get_openapi_parameters(TEST_ENDPOINT, TEST_METHOD) expected_item = { "name": "message_id", "in": "path", "description": "The target message's ID.\n", "example": 42, "required": True, "schema": {"type": "integer"}, } assert expected_item in actual
def test_get_openapi_parameters(self) -> None: actual = get_openapi_parameters(TEST_ENDPOINT, TEST_METHOD) expected_item = { 'name': 'message_id', 'in': 'path', 'description': 'The ID of the message that you wish to edit/update.', 'example': 42, 'required': True, 'schema': {'type': 'integer'} } assert(expected_item in actual)
def test_get_openapi_parameters(self) -> None: actual = get_openapi_parameters(TEST_ENDPOINT, TEST_METHOD) expected_item = { 'name': 'message_id', 'in': 'path', 'description': 'The target message\'s ID.\n', 'example': 42, 'required': True, 'schema': {'type': 'integer'}, } assert(expected_item in actual)
def test_openapi_arguments(self) -> None: """This end-to-end API documentation test compares the arguments defined in the actual code using @has_request_variables and REQ(), with the arguments declared in our API documentation for every API endpoint in Zulip. First, we import the fancy-Django version of zproject/urls.py by doing this, each has_request_variables wrapper around each imported view function gets called to generate the wrapped view function and thus filling the global arguments_map variable. Basically, we're exploiting code execution during import. Then we need to import some view modules not already imported in urls.py. We use this different syntax because of the linters complaining of an unused import (which is correct, but we do this for triggering the has_request_variables decorator). At the end, we perform a reverse mapping test that verifies that every url pattern defined in the openapi documentation actually exists in code. """ from zproject import urls as urlconf # We loop through all the API patterns, looking in particular # for those using the rest_dispatch decorator; we then parse # its mapping of (HTTP_METHOD -> FUNCTION). for p in urlconf.v1_api_and_json_patterns + urlconf.v1_api_mobile_patterns: if p.lookup_str != 'zerver.lib.rest.rest_dispatch': # Endpoints not using rest_dispatch don't have extra data. methods_endpoints = dict(GET=p.lookup_str, ) else: methods_endpoints = p.default_args # since the module was already imported and is now residing in # memory, we won't actually face any performance penalties here. for method, value in methods_endpoints.items(): if isinstance(value, str): function_name = value tags: Set[str] = set() else: function_name, tags = value if function_name == 'zerver.tornado.views.get_events': # Work around the fact that the registered # get_events view function isn't where we do # @has_request_variables. # # TODO: Make this configurable via an optional argument # to has_request_variables, e.g. # @has_request_variables(view_func_name="zerver.tornado.views.get_events") function_name = 'zerver.tornado.views.get_events_backend' lookup_parts = function_name.split('.') module = __import__('.'.join(lookup_parts[:-1]), {}, {}, ['']) function = getattr(module, lookup_parts[-1]) # Our accounting logic in the `has_request_variables()` # code means we have the list of all arguments # accepted by every view function in arguments_map. accepted_arguments = set(arguments_map[function_name]) regex_pattern = p.pattern.regex.pattern url_pattern = self.convert_regex_to_url_pattern(regex_pattern) if "intentionally_undocumented" in tags: self.ensure_no_documentation_if_intentionally_undocumented( url_pattern, method) continue if url_pattern in self.pending_endpoints: # HACK: After all pending_endpoints have been resolved, we should remove # this segment and the "msg" part of the `ensure_no_...` method. msg = """ We found some OpenAPI documentation for {method} {url_pattern}, so maybe we shouldn't include it in pending_endpoints. """.format(method=method, url_pattern=url_pattern) self.ensure_no_documentation_if_intentionally_undocumented( url_pattern, method, msg) continue try: # Don't include OpenAPI parameters that live in # the path; these are not extracted by REQ. openapi_parameters = get_openapi_parameters( url_pattern, method, include_url_parameters=False) except Exception: # nocoverage raise AssertionError( "Could not find OpenAPI docs for %s %s" % (method, url_pattern)) # We now have everything we need to understand the # function as defined in our urls.py: # # * method is the HTTP method, e.g. GET, POST, or PATCH # # * p.pattern.regex.pattern is the URL pattern; might require # some processing to match with OpenAPI rules # # * accepted_arguments is the full set of arguments # this method accepts (from the REQ declarations in # code). # # * The documented parameters for the endpoint as recorded in our # OpenAPI data in zerver/openapi/zulip.yaml. # # We now compare these to confirm that the documented # argument list matches what actually appears in the # codebase. openapi_parameter_names = { parameter['name'] for parameter in openapi_parameters } if len(accepted_arguments - openapi_parameter_names) > 0: # nocoverage print("Undocumented parameters for", url_pattern, method, function_name) print(" +", openapi_parameter_names) print(" -", accepted_arguments) assert (url_pattern in self.buggy_documentation_endpoints) elif len(openapi_parameter_names - accepted_arguments) > 0: # nocoverage print("Documented invalid parameters for", url_pattern, method, function_name) print(" -", openapi_parameter_names) print(" +", accepted_arguments) assert (url_pattern in self.buggy_documentation_endpoints) else: self.assertEqual(openapi_parameter_names, accepted_arguments) self.check_argument_types(function, openapi_parameters) self.checked_endpoints.add(url_pattern) self.check_for_non_existant_openapi_endpoints()