mirror of https://github.com/zulip/zulip.git
550 lines
18 KiB
Python
550 lines
18 KiB
Python
# Zulip's OpenAPI-based API documentation system is documented at
|
|
# https://zulip.readthedocs.io/en/latest/documentation/api.html
|
|
#
|
|
# This file defines the special Markdown extension that is used to
|
|
# render the code examples, example responses, etc. that appear in
|
|
# Zulip's public API documentation.
|
|
|
|
import inspect
|
|
import json
|
|
import re
|
|
import shlex
|
|
from collections.abc import Mapping
|
|
from re import Match, Pattern
|
|
from textwrap import dedent
|
|
from typing import Any
|
|
|
|
import markdown
|
|
from django.conf import settings
|
|
from markdown.extensions import Extension
|
|
from markdown.preprocessors import Preprocessor
|
|
from typing_extensions import override
|
|
|
|
import zerver.openapi.python_examples
|
|
from zerver.lib.markdown.priorities import PREPROCESSOR_PRIORITIES
|
|
from zerver.openapi.openapi import (
|
|
NO_EXAMPLE,
|
|
Parameter,
|
|
check_additional_imports,
|
|
check_requires_administrator,
|
|
generate_openapi_fixture,
|
|
get_curl_include_exclude,
|
|
get_openapi_description,
|
|
get_openapi_parameters,
|
|
get_openapi_summary,
|
|
get_parameters_description,
|
|
get_responses_description,
|
|
openapi_spec,
|
|
)
|
|
|
|
API_ENDPOINT_NAME = r"/[a-z_\-/-{}]+:[a-z]+"
|
|
API_LANGUAGE = r"\w+"
|
|
API_KEY_TYPE = r"fixture|example"
|
|
MACRO_REGEXP = re.compile(
|
|
rf"""
|
|
{{
|
|
generate_code_example
|
|
(?: \( \s* ({API_LANGUAGE}) \s* \) )?
|
|
\|
|
|
\s* ({API_ENDPOINT_NAME}) \s*
|
|
\|
|
|
\s* ({API_KEY_TYPE}) \s*
|
|
}}
|
|
""",
|
|
re.VERBOSE,
|
|
)
|
|
PYTHON_EXAMPLE_REGEX = re.compile(r"\# \{code_example\|\s*(start|end)\s*\}")
|
|
JS_EXAMPLE_REGEX = re.compile(r"\/\/ \{code_example\|\s*(start|end)\s*\}")
|
|
MACRO_REGEXP_HEADER = re.compile(rf"{{generate_api_header\(\s*({API_ENDPOINT_NAME})\s*\)}}")
|
|
MACRO_REGEXP_RESPONSE_DESC = re.compile(
|
|
rf"{{generate_response_description\(\s*({API_ENDPOINT_NAME})\s*\)}}"
|
|
)
|
|
MACRO_REGEXP_PARAMETER_DESC = re.compile(
|
|
rf"{{generate_parameter_description\(\s*({API_ENDPOINT_NAME})\s*\)}}"
|
|
)
|
|
|
|
PYTHON_CLIENT_CONFIG = """
|
|
#!/usr/bin/env python3
|
|
|
|
import zulip
|
|
|
|
# Pass the path to your zuliprc file here.
|
|
client = zulip.Client(config_file="~/zuliprc")
|
|
|
|
"""
|
|
|
|
PYTHON_CLIENT_ADMIN_CONFIG = """
|
|
#!/usr/bin/env python
|
|
|
|
import zulip
|
|
|
|
# The user for this zuliprc file must be an organization administrator
|
|
client = zulip.Client(config_file="~/zuliprc-admin")
|
|
|
|
"""
|
|
|
|
JS_CLIENT_CONFIG = """
|
|
const zulipInit = require("zulip-js");
|
|
|
|
// Pass the path to your zuliprc file here.
|
|
const config = { zuliprc: "zuliprc" };
|
|
|
|
"""
|
|
|
|
JS_CLIENT_ADMIN_CONFIG = """
|
|
const zulipInit = require("zulip-js");
|
|
|
|
// The user for this zuliprc file must be an organization administrator.
|
|
const config = { zuliprc: "zuliprc-admin" };
|
|
|
|
"""
|
|
|
|
DEFAULT_AUTH_EMAIL = "BOT_EMAIL_ADDRESS"
|
|
DEFAULT_AUTH_API_KEY = "BOT_API_KEY"
|
|
DEFAULT_EXAMPLE = {
|
|
"integer": 1,
|
|
"string": "demo",
|
|
"boolean": False,
|
|
}
|
|
ADMIN_CONFIG_LANGUAGES = ["python", "javascript"]
|
|
|
|
|
|
def extract_code_example(
|
|
source: list[str], snippet: list[Any], example_regex: Pattern[str]
|
|
) -> list[Any]:
|
|
start = -1
|
|
end = -1
|
|
for line in source:
|
|
match = example_regex.search(line)
|
|
if match:
|
|
if match.group(1) == "start":
|
|
start = source.index(line)
|
|
elif match.group(1) == "end":
|
|
end = source.index(line)
|
|
break
|
|
|
|
if start == -1 and end == -1:
|
|
return snippet
|
|
|
|
snippet.append(source[start + 1 : end])
|
|
source = source[end + 1 :]
|
|
return extract_code_example(source, snippet, example_regex)
|
|
|
|
|
|
def render_python_code_example(
|
|
function: str, admin_config: bool = False, **kwargs: Any
|
|
) -> list[str]:
|
|
if function not in zerver.openapi.python_examples.TEST_FUNCTIONS:
|
|
return []
|
|
method = zerver.openapi.python_examples.TEST_FUNCTIONS[function]
|
|
function_source_lines = inspect.getsourcelines(method)[0]
|
|
|
|
if admin_config:
|
|
config_string = PYTHON_CLIENT_ADMIN_CONFIG
|
|
else:
|
|
config_string = PYTHON_CLIENT_CONFIG
|
|
|
|
endpoint, endpoint_method = function.split(":")
|
|
extra_imports = check_additional_imports(endpoint, endpoint_method)
|
|
if extra_imports:
|
|
extra_imports = sorted([*extra_imports, "zulip"])
|
|
extra_imports = [f"import {each_import}" for each_import in extra_imports]
|
|
config_string = config_string.replace("import zulip", "\n".join(extra_imports))
|
|
|
|
config = config_string.splitlines()
|
|
|
|
snippets = extract_code_example(function_source_lines, [], PYTHON_EXAMPLE_REGEX)
|
|
|
|
return [
|
|
"{tab|python}\n",
|
|
"```python",
|
|
*config,
|
|
# Remove one level of indentation and strip newlines
|
|
*(line.removeprefix(" ").rstrip() for snippet in snippets for line in snippet),
|
|
"print(result)",
|
|
"\n",
|
|
"```",
|
|
]
|
|
|
|
|
|
def render_javascript_code_example(
|
|
function: str, admin_config: bool = False, **kwargs: Any
|
|
) -> list[str]:
|
|
pattern = rf'^add_example\(\s*"[^"]*",\s*{re.escape(json.dumps(function))},\s*\d+,\s*async \(client, console\) => \{{\n(.*?)^(?:\}}| *\}},\n)\);$'
|
|
with open("zerver/openapi/javascript_examples.js") as f:
|
|
m = re.search(pattern, f.read(), re.MULTILINE | re.DOTALL)
|
|
if m is None:
|
|
return []
|
|
function_source_lines = dedent(m.group(1)).splitlines()
|
|
|
|
snippets = extract_code_example(function_source_lines, [], JS_EXAMPLE_REGEX)
|
|
|
|
if admin_config:
|
|
config = JS_CLIENT_ADMIN_CONFIG.splitlines()
|
|
else:
|
|
config = JS_CLIENT_CONFIG.splitlines()
|
|
|
|
code_example = [
|
|
"{tab|js}\n",
|
|
"More examples and documentation can be found [here](https://github.com/zulip/zulip-js).",
|
|
]
|
|
|
|
code_example.append("```js")
|
|
code_example.extend(config)
|
|
code_example.append("(async () => {")
|
|
code_example.append(" const client = await zulipInit(config);")
|
|
for snippet in snippets:
|
|
code_example.append("")
|
|
# Strip newlines
|
|
code_example.extend(" " + line.rstrip() for line in snippet)
|
|
code_example.append("})();")
|
|
|
|
code_example.append("```")
|
|
|
|
return code_example
|
|
|
|
|
|
def curl_method_arguments(endpoint: str, method: str, api_url: str) -> list[str]:
|
|
# We also include the -sS verbosity arguments here.
|
|
method = method.upper()
|
|
url = f"{api_url}/v1{endpoint}"
|
|
valid_methods = ["GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS"]
|
|
if method == "GET":
|
|
# Then we need to make sure that each -d option translates to becoming
|
|
# a GET parameter (in the URL) and not a POST parameter (in the body).
|
|
# TODO: remove the -X part by updating the linting rule. It's redundant.
|
|
return ["-sSX", "GET", "-G", url]
|
|
elif method in valid_methods:
|
|
return ["-sSX", method, url]
|
|
else:
|
|
msg = f"The request method {method} is not one of {valid_methods}"
|
|
raise ValueError(msg)
|
|
|
|
|
|
def get_openapi_param_example_value_as_string(
|
|
endpoint: str, method: str, parameter: Parameter, curl_argument: bool = False
|
|
) -> str:
|
|
if "type" in parameter.value_schema:
|
|
param_type = parameter.value_schema["type"]
|
|
else:
|
|
# Hack: Ideally, we'd extract a common function for handling
|
|
# oneOf values in types and do something with the resulting
|
|
# union type. But for this logic's purpose, it's good enough
|
|
# to just check the first parameter.
|
|
param_type = parameter.value_schema["oneOf"][0]["type"]
|
|
|
|
if param_type in ["object", "array"]:
|
|
if parameter.example is NO_EXAMPLE:
|
|
msg = f"""All array and object type request parameters must have
|
|
concrete examples. The openAPI documentation for {endpoint}/{method} is missing an example
|
|
value for the {parameter.name} parameter. Without this we cannot automatically generate a
|
|
cURL example."""
|
|
raise ValueError(msg)
|
|
ordered_ex_val_str = json.dumps(parameter.example, sort_keys=True)
|
|
# We currently don't have any non-JSON encoded arrays.
|
|
assert parameter.json_encoded
|
|
if curl_argument:
|
|
return " --data-urlencode " + shlex.quote(f"{parameter.name}={ordered_ex_val_str}")
|
|
return ordered_ex_val_str # nocoverage
|
|
else:
|
|
if parameter.example is NO_EXAMPLE:
|
|
example_value = DEFAULT_EXAMPLE[param_type]
|
|
else:
|
|
example_value = parameter.example
|
|
|
|
# Booleans are effectively JSON-encoded, in that we pass
|
|
# true/false, not the Python str(True) = "True"
|
|
if parameter.json_encoded or isinstance(example_value, bool | float | int):
|
|
example_value = json.dumps(example_value)
|
|
else:
|
|
assert isinstance(example_value, str)
|
|
|
|
if curl_argument:
|
|
return " --data-urlencode " + shlex.quote(f"{parameter.name}={example_value}")
|
|
return example_value
|
|
|
|
|
|
def generate_curl_example(
|
|
endpoint: str,
|
|
method: str,
|
|
api_url: str,
|
|
auth_email: str = DEFAULT_AUTH_EMAIL,
|
|
auth_api_key: str = DEFAULT_AUTH_API_KEY,
|
|
exclude: list[str] | None = None,
|
|
include: list[str] | None = None,
|
|
) -> list[str]:
|
|
lines = ["```curl"]
|
|
operation = endpoint + ":" + method.lower()
|
|
operation_entry = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
|
global_security = openapi_spec.openapi()["security"]
|
|
|
|
parameters = get_openapi_parameters(endpoint, method)
|
|
operation_request_body = operation_entry.get("requestBody", None)
|
|
operation_security = operation_entry.get("security", None)
|
|
|
|
if settings.RUNNING_OPENAPI_CURL_TEST: # nocoverage
|
|
from zerver.openapi.curl_param_value_generators import patch_openapi_example_values
|
|
|
|
parameters, operation_request_body = patch_openapi_example_values(
|
|
operation, parameters, operation_request_body
|
|
)
|
|
|
|
format_dict = {}
|
|
for parameter in parameters:
|
|
if parameter.kind != "path":
|
|
continue
|
|
example_value = get_openapi_param_example_value_as_string(endpoint, method, parameter)
|
|
format_dict[parameter.name] = example_value
|
|
example_endpoint = endpoint.format_map(format_dict)
|
|
|
|
curl_first_line_parts = ["curl", *curl_method_arguments(example_endpoint, method, api_url)]
|
|
lines.append(shlex.join(curl_first_line_parts))
|
|
|
|
insecure_operations = ["/dev_fetch_api_key:post", "/fetch_api_key:post"]
|
|
if operation_security is None:
|
|
if global_security == [{"basicAuth": []}]:
|
|
authentication_required = True
|
|
else:
|
|
raise AssertionError(
|
|
"Unhandled global securityScheme. Please update the code to handle this scheme."
|
|
)
|
|
elif operation_security == []:
|
|
if operation in insecure_operations:
|
|
authentication_required = False
|
|
else:
|
|
raise AssertionError(
|
|
"Unknown operation without a securityScheme. Please update insecure_operations."
|
|
)
|
|
else:
|
|
raise AssertionError(
|
|
"Unhandled securityScheme. Please update the code to handle this scheme."
|
|
)
|
|
|
|
if authentication_required:
|
|
lines.append(" -u " + shlex.quote(f"{auth_email}:{auth_api_key}"))
|
|
|
|
for parameter in parameters:
|
|
if parameter.kind == "path":
|
|
continue
|
|
|
|
if include is not None and parameter.name not in include:
|
|
continue
|
|
|
|
if exclude is not None and parameter.name in exclude:
|
|
continue
|
|
|
|
example_value = get_openapi_param_example_value_as_string(
|
|
endpoint, method, parameter, curl_argument=True
|
|
)
|
|
lines.append(example_value)
|
|
|
|
if "requestBody" in operation_entry and "multipart/form-data" in (
|
|
content := operation_entry["requestBody"]["content"]
|
|
):
|
|
properties = content["multipart/form-data"]["schema"]["properties"]
|
|
for key, property in properties.items():
|
|
lines.append(" -F " + shlex.quote("{}=@{}".format(key, property["example"])))
|
|
|
|
for i in range(1, len(lines) - 1):
|
|
lines[i] += " \\"
|
|
|
|
lines.append("```")
|
|
|
|
return lines
|
|
|
|
|
|
def render_curl_example(
|
|
function: str,
|
|
api_url: str,
|
|
admin_config: bool = False,
|
|
) -> list[str]:
|
|
"""A simple wrapper around generate_curl_example."""
|
|
parts = function.split(":")
|
|
endpoint = parts[0]
|
|
method = parts[1]
|
|
kwargs: dict[str, Any] = {}
|
|
if len(parts) > 2:
|
|
kwargs["auth_email"] = parts[2]
|
|
if len(parts) > 3:
|
|
kwargs["auth_api_key"] = parts[3]
|
|
kwargs["api_url"] = api_url
|
|
rendered_example = []
|
|
for element in get_curl_include_exclude(endpoint, method):
|
|
kwargs["include"] = None
|
|
kwargs["exclude"] = None
|
|
if element["type"] == "include":
|
|
kwargs["include"] = element["parameters"]["enum"]
|
|
if element["type"] == "exclude":
|
|
kwargs["exclude"] = element["parameters"]["enum"]
|
|
if "description" in element:
|
|
rendered_example.extend(element["description"].splitlines())
|
|
rendered_example += generate_curl_example(endpoint, method, **kwargs)
|
|
return rendered_example
|
|
|
|
|
|
SUPPORTED_LANGUAGES: dict[str, Any] = {
|
|
"python": {
|
|
"client_config": PYTHON_CLIENT_CONFIG,
|
|
"admin_config": PYTHON_CLIENT_ADMIN_CONFIG,
|
|
"render": render_python_code_example,
|
|
},
|
|
"curl": {
|
|
"render": render_curl_example,
|
|
},
|
|
"javascript": {
|
|
"client_config": JS_CLIENT_CONFIG,
|
|
"admin_config": JS_CLIENT_ADMIN_CONFIG,
|
|
"render": render_javascript_code_example,
|
|
},
|
|
}
|
|
|
|
|
|
class APIMarkdownExtension(Extension):
|
|
def __init__(self, api_url: str | None) -> None:
|
|
self.config = {
|
|
"api_url": [
|
|
api_url,
|
|
"API URL to use when rendering curl examples",
|
|
],
|
|
}
|
|
|
|
@override
|
|
def extendMarkdown(self, md: markdown.Markdown) -> None:
|
|
md.preprocessors.register(
|
|
APICodeExamplesPreprocessor(md, self.getConfigs()),
|
|
"generate_code_example",
|
|
PREPROCESSOR_PRIORITIES["generate_code_example"],
|
|
)
|
|
md.preprocessors.register(
|
|
APIHeaderPreprocessor(md, self.getConfigs()),
|
|
"generate_api_header",
|
|
PREPROCESSOR_PRIORITIES["generate_api_header"],
|
|
)
|
|
md.preprocessors.register(
|
|
ResponseDescriptionPreprocessor(md, self.getConfigs()),
|
|
"generate_response_description",
|
|
PREPROCESSOR_PRIORITIES["generate_response_description"],
|
|
)
|
|
md.preprocessors.register(
|
|
ParameterDescriptionPreprocessor(md, self.getConfigs()),
|
|
"generate_parameter_description",
|
|
PREPROCESSOR_PRIORITIES["generate_parameter_description"],
|
|
)
|
|
|
|
|
|
class BasePreprocessor(Preprocessor):
|
|
def __init__(
|
|
self, regexp: Pattern[str], md: markdown.Markdown, config: Mapping[str, Any]
|
|
) -> None:
|
|
super().__init__(md)
|
|
self.api_url = config["api_url"]
|
|
self.REGEXP = regexp
|
|
|
|
@override
|
|
def run(self, lines: list[str]) -> list[str]:
|
|
done = False
|
|
while not done:
|
|
for line in lines:
|
|
loc = lines.index(line)
|
|
match = self.REGEXP.search(line)
|
|
|
|
if match:
|
|
text = self.generate_text(match)
|
|
|
|
# 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 = self.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 generate_text(self, match: Match[str]) -> list[str]:
|
|
function = match.group(1)
|
|
text = self.render(function)
|
|
return text
|
|
|
|
def render(self, function: str) -> list[str]:
|
|
raise NotImplementedError("Must be overridden by a child class")
|
|
|
|
|
|
class APICodeExamplesPreprocessor(BasePreprocessor):
|
|
def __init__(self, md: markdown.Markdown, config: Mapping[str, Any]) -> None:
|
|
super().__init__(MACRO_REGEXP, md, config)
|
|
|
|
@override
|
|
def generate_text(self, match: Match[str]) -> list[str]:
|
|
language = match.group(1) or ""
|
|
function = match.group(2)
|
|
key = match.group(3)
|
|
if self.api_url is None:
|
|
raise AssertionError("Cannot render curl API examples without API URL set.")
|
|
|
|
if key == "fixture":
|
|
text = self.render(function)
|
|
elif key == "example":
|
|
path, method = function.rsplit(":", 1)
|
|
admin_config = language in ADMIN_CONFIG_LANGUAGES and check_requires_administrator(
|
|
path, method
|
|
)
|
|
text = SUPPORTED_LANGUAGES[language]["render"](
|
|
function, api_url=self.api_url, admin_config=admin_config
|
|
)
|
|
return text
|
|
|
|
@override
|
|
def render(self, function: str) -> list[str]:
|
|
path, method = function.rsplit(":", 1)
|
|
return generate_openapi_fixture(path, method)
|
|
|
|
|
|
class APIHeaderPreprocessor(BasePreprocessor):
|
|
def __init__(self, md: markdown.Markdown, config: Mapping[str, Any]) -> None:
|
|
super().__init__(MACRO_REGEXP_HEADER, md, config)
|
|
|
|
@override
|
|
def render(self, function: str) -> list[str]:
|
|
path, method = function.rsplit(":", 1)
|
|
raw_title = get_openapi_summary(path, method)
|
|
description_dict = get_openapi_description(path, method)
|
|
return [
|
|
*("# " + line for line in raw_title.splitlines()),
|
|
*(["{!api-admin-only.md!}"] if check_requires_administrator(path, method) else []),
|
|
"",
|
|
f"`{method.upper()} {self.api_url}/v1{path}`",
|
|
"",
|
|
*description_dict.splitlines(),
|
|
]
|
|
|
|
|
|
class ResponseDescriptionPreprocessor(BasePreprocessor):
|
|
def __init__(self, md: markdown.Markdown, config: Mapping[str, Any]) -> None:
|
|
super().__init__(MACRO_REGEXP_RESPONSE_DESC, md, config)
|
|
|
|
@override
|
|
def render(self, function: str) -> list[str]:
|
|
path, method = function.rsplit(":", 1)
|
|
raw_description = get_responses_description(path, method)
|
|
return raw_description.splitlines()
|
|
|
|
|
|
class ParameterDescriptionPreprocessor(BasePreprocessor):
|
|
def __init__(self, md: markdown.Markdown, config: Mapping[str, Any]) -> None:
|
|
super().__init__(MACRO_REGEXP_PARAMETER_DESC, md, config)
|
|
|
|
@override
|
|
def render(self, function: str) -> list[str]:
|
|
path, method = function.rsplit(":", 1)
|
|
raw_description = get_parameters_description(path, method)
|
|
return raw_description.splitlines()
|
|
|
|
|
|
def makeExtension(*args: Any, **kwargs: str) -> APIMarkdownExtension:
|
|
return APIMarkdownExtension(*args, **kwargs)
|