import json import os import re from typing import Any, Dict, List, Mapping, Sequence import markdown from django.utils.html import escape as escape_html from markdown.extensions import Extension from markdown.preprocessors import Preprocessor from zerver.lib.markdown.preprocessor_priorities import PREPROCESSOR_PRIORITES from zerver.openapi.openapi import ( check_deprecated_consistency, get_openapi_parameters, get_parameters_description, ) REGEXP = re.compile(r"\{generate_api_arguments_table\|\s*(.+?)\s*\|\s*(.+)\s*\}") class MarkdownArgumentsTableGenerator(Extension): def __init__(self, configs: Mapping[str, Any] = {}) -> None: self.config = { "base_path": [ ".", "Default location from which to evaluate relative paths for the JSON files.", ], } for key, value in configs.items(): self.setConfig(key, value) def extendMarkdown(self, md: markdown.Markdown) -> None: md.preprocessors.register( APIArgumentsTablePreprocessor(md, self.getConfigs()), "generate_api_arguments", PREPROCESSOR_PRIORITES["generate_api_arguments"], ) class APIArgumentsTablePreprocessor(Preprocessor): def __init__(self, md: markdown.Markdown, config: Mapping[str, Any]) -> None: super().__init__(md) self.base_path = config["base_path"] 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 render_table(self, arguments: Sequence[Mapping[str, Any]]) -> List[str]: # TODO: Fix naming now that this no longer renders a table. table = [] argument_template = """
""" md_engine = markdown.Markdown(extensions=[]) arguments = sorted(arguments, key=lambda argument: "deprecated" in argument) for argument in arguments: description = argument["description"] oneof = ["`" + str(item) + "`" for item in argument.get("schema", {}).get("enum", [])] if oneof: description += "\nMust be one of: {}.".format(", ".join(oneof)) default = argument.get("schema", {}).get("default") if default is not None: description += f"\nDefaults to `{json.dumps(default)}`." data_type = "" if "schema" in argument: data_type = generate_data_type(argument["schema"]) else: data_type = generate_data_type(argument["content"]["application/json"]["schema"]) # TODO: OpenAPI allows indicating where the argument goes # (path, querystring, form data...). We should document this detail. example = "" if "example" in argument: # We use this style without explicit JSON encoding for # integers, strings, and booleans. # * For booleans, JSON encoding correctly corrects for Python's # str(True)="True" not matching the encoding of "true". # * For strings, doing so nicely results in strings being quoted # in the documentation, improving readability. # * For integers, it is a noop, since json.dumps(3) == str(3) == "3". example = json.dumps(argument["example"]) else: example = json.dumps(argument["content"]["application/json"]["example"]) required_string: str = "required" if argument.get("in", "") == "path": # Any path variable is required assert argument["required"] required_string = "required in path" if argument.get("required", False): required_block = f'{required_string}' else: required_block = 'optional' check_deprecated_consistency(argument, description) if argument.get("deprecated", False): deprecated_block = 'Deprecated' else: deprecated_block = "" table.append( argument_template.format( argument=argument.get("argument") or argument.get("name"), example=escape_html(example), required=required_block, deprecated=deprecated_block, description=md_engine.convert(description), type=data_type, ) ) return table def makeExtension(*args: Any, **kwargs: str) -> MarkdownArgumentsTableGenerator: return MarkdownArgumentsTableGenerator(kwargs) def generate_data_type(schema: Mapping[str, Any]) -> str: data_type = "" if "oneOf" in schema: for item in schema["oneOf"]: data_type = data_type + generate_data_type(item) + " | " data_type = data_type[:-3] elif "items" in schema: data_type = "(" + generate_data_type(schema["items"]) + ")[]" else: data_type = schema["type"] return data_type