import json import re from collections.abc import Mapping, Sequence from typing import Any import markdown from django.utils.html import escape as escape_html from markdown.extensions import Extension from markdown.preprocessors import Preprocessor from typing_extensions import override from zerver.lib.markdown.priorities import PREPROCESSOR_PRIORITIES from zerver.openapi.openapi import ( Parameter, check_deprecated_consistency, get_openapi_parameters, get_parameters_description, ) REGEXP = re.compile(r"\{generate_api_arguments_table\|\s*(.+?)\s*\|\s*(.+)\s*\}") API_PARAMETER_TEMPLATE = """

{argument} {type} {required} {deprecated}

Example: {example}
{description}{object_details}

""".strip() OBJECT_DETAILS_TEMPLATE = """

{argument} object details:

""".strip() ONEOF_OBJECT_DETAILS_TEMPLATE = """

An object with the following fields:

""".strip() OBJECT_LIST_ITEM_TEMPLATE = """
  • {value}: {data_type} {required} {description}{object_details}
  • """.strip() OBJECT_DESCRIPTION_TEMPLATE = """ {description}

    {additional_information}

    """.strip() OBJECT_CODE_TEMPLATE = "{value}".strip() ONEOF_DETAILS_TEMPLATE = """

    This parameter must be one of the following:

      {values}
    """.strip() ONEOF_LIST_ITEM_TEMPLATE = """
  • {item}
  • """.strip() class MarkdownArgumentsTableGenerator(Extension): @override def extendMarkdown(self, md: markdown.Markdown) -> None: md.preprocessors.register( APIArgumentsTablePreprocessor(md, self.getConfigs()), "generate_api_arguments", PREPROCESSOR_PRIORITIES["generate_api_arguments"], ) class APIArgumentsTablePreprocessor(Preprocessor): def __init__(self, md: markdown.Markdown, config: Mapping[str, Any]) -> None: super().__init__(md) @override 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 doc_name = match.group(2) endpoint, method = doc_name.rsplit(":", 1) parameters = get_openapi_parameters(endpoint, method) if parameters: text = self.render_parameters(parameters) # We want to show this message only if the parameters # description doesn't say anything else. elif 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_oneof_block(self, object_schema: dict[str, Any], name: str) -> str: md_engine = markdown.Markdown(extensions=[]) content = "" for element in object_schema["oneOf"]: if "items" in element and "properties" in element["items"]: content += ONEOF_LIST_ITEM_TEMPLATE.format( item=self.render_object_details(element["items"], str(name), True) ) elif "properties" in element: content += ONEOF_LIST_ITEM_TEMPLATE.format( item=self.render_object_details(element, str(name), True) ) elif "description" in element: content += ONEOF_LIST_ITEM_TEMPLATE.format( item=md_engine.convert(element["description"]) ) return ONEOF_DETAILS_TEMPLATE.format(values=content) def render_parameters(self, parameters: Sequence[Parameter]) -> list[str]: lines = [] md_engine = markdown.Markdown(extensions=[]) parameters = sorted(parameters, key=lambda parameter: parameter.deprecated) for parameter in parameters: name = parameter.name description = parameter.description enums = parameter.value_schema.get("enum") if enums is not None: formatted_enums = [ OBJECT_CODE_TEMPLATE.format(value=json.dumps(enum)) for enum in enums ] description += "\nMust be one of: {}. ".format(", ".join(formatted_enums)) default = parameter.value_schema.get("default") if default is not None: description += f"\nDefaults to `{json.dumps(default)}`." data_type = generate_data_type(parameter.value_schema) # TODO: OpenAPI allows indicating where the argument goes # (path, querystring, form data...). We should document this detail. # 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(parameter.example) required_string: str = "required" if parameter.kind == "path": # Any path variable is required assert parameter.required required_string = "required in path" if parameter.required: required_block = f'{required_string}' else: required_block = 'optional' check_deprecated_consistency(parameter.deprecated, description) if parameter.deprecated: deprecated_block = 'Deprecated' else: deprecated_block = "" object_block = "" # TODO: There are some endpoint parameters with object properties # that are not defined in `zerver/openapi/zulip.yaml` if "object" in data_type: object_schema = parameter.value_schema if "items" in object_schema and "properties" in object_schema["items"]: object_block = self.render_object_details(object_schema["items"], str(name)) elif "properties" in object_schema: object_block = self.render_object_details(object_schema, str(name)) elif "oneOf" in object_schema: object_block = self.render_oneof_block(object_schema, str(name)) lines.append( API_PARAMETER_TEMPLATE.format( argument=name, example=escape_html(example), required=required_block, deprecated=deprecated_block, description=md_engine.convert(description), type=data_type, object_details=object_block, ) ) return lines def render_object_details( self, schema: Mapping[str, Any], name: str, oneof: bool = False ) -> str: md_engine = markdown.Markdown(extensions=[]) li_elements = [] object_values = schema.get("properties", {}) for value in object_values: description = "" if "description" in object_values[value]: description = object_values[value]["description"] # check for default, enum, required or example in documentation additions: list[str] = [] default = object_values.get(value, {}).get("default") if default is not None: formatted_default = OBJECT_CODE_TEMPLATE.format(value=json.dumps(default)) additions += f"Defaults to {formatted_default}. " enums = object_values.get(value, {}).get("enum") if enums is not None: formatted_enums = [ OBJECT_CODE_TEMPLATE.format(value=json.dumps(enum)) for enum in enums ] additions += "Must be one of: {}. ".format(", ".join(formatted_enums)) if "example" in object_values[value]: example = json.dumps(object_values[value]["example"]) formatted_example = OBJECT_CODE_TEMPLATE.format(value=escape_html(example)) additions += ( f'Example: {formatted_example}' ) if len(additions) > 0: additional_information = "".join(additions).strip() description_final = OBJECT_DESCRIPTION_TEMPLATE.format( description=md_engine.convert(description), additional_information=additional_information, ) else: description_final = md_engine.convert(description) required_block = "" if "required" in schema: if value in schema["required"]: required_block = 'required' else: required_block = 'optional' data_type = generate_data_type(object_values[value]) details = "" if "object" in data_type and "properties" in object_values[value]: details += self.render_object_details(object_values[value], str(value)) elif "oneOf" in object_values[value]: details += self.render_oneof_block(object_values[value], str(value)) li = OBJECT_LIST_ITEM_TEMPLATE.format( value=value, data_type=data_type, required=required_block, description=description_final, object_details=details, ) li_elements.append(li) if oneof: object_details = ONEOF_OBJECT_DETAILS_TEMPLATE.format( values="\n".join(li_elements), ) else: object_details = OBJECT_DETAILS_TEMPLATE.format( argument=name, values="\n".join(li_elements), ) return object_details def makeExtension(*args: Any, **kwargs: str) -> MarkdownArgumentsTableGenerator: return MarkdownArgumentsTableGenerator(*args, **kwargs) def generate_data_type(schema: Mapping[str, Any]) -> str: data_type = "" if "oneOf" in schema: data_type = " | ".join(generate_data_type(item) for item in schema["oneOf"]) elif "items" in schema: data_type = "(" + generate_data_type(schema["items"]) + ")[]" else: data_type = schema["type"] if schema.get("nullable", False): data_type += " | null" return data_type