snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
0
zerver/openapi/__init__.py
Normal file
0
zerver/openapi/__init__.py
Normal file
571
zerver/openapi/curl_param_value_generators.py
Normal file
571
zerver/openapi/curl_param_value_generators.py
Normal file
@@ -0,0 +1,571 @@
|
||||
# Zulip's OpenAPI-based API documentation system is documented at
|
||||
# https://zulip.readthedocs.io/en/latest/documentation/api.html
|
||||
#
|
||||
# This file contains helper functions for generating cURL examples
|
||||
# based on Zulip's OpenAPI definitions, as well as test setup and
|
||||
# fetching of appropriate parameter values to use when running the
|
||||
# cURL examples as part of the tools/test-api test suite.
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, cast
|
||||
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from zerver.actions.channel_folders import check_add_channel_folder
|
||||
from zerver.actions.create_user import do_create_user
|
||||
from zerver.actions.custom_profile_fields import try_add_realm_custom_profile_field
|
||||
from zerver.actions.presence import update_user_presence
|
||||
from zerver.actions.reactions import do_add_reaction
|
||||
from zerver.actions.realm_domains import do_add_realm_domain
|
||||
from zerver.actions.realm_linkifiers import do_add_linkifier
|
||||
from zerver.actions.realm_playgrounds import check_add_realm_playground
|
||||
from zerver.lib.bot_storage import set_bot_storage
|
||||
from zerver.lib.events import do_events_register
|
||||
from zerver.lib.initial_password import initial_password
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import read_test_image_file
|
||||
from zerver.lib.types import ProfileFieldData
|
||||
from zerver.lib.upload import upload_message_attachment
|
||||
from zerver.models import Client, CustomProfileField, Message, NamedUserGroup, UserPresence
|
||||
from zerver.models.channel_folders import ChannelFolder
|
||||
from zerver.models.realms import RealmExport, get_realm
|
||||
from zerver.models.users import UserProfile, get_user
|
||||
from zerver.openapi.openapi import Parameter
|
||||
|
||||
GENERATOR_FUNCTIONS: dict[str, Callable[[], dict[str, object]]] = {}
|
||||
REGISTERED_GENERATOR_FUNCTIONS: set[str] = set()
|
||||
CALLED_GENERATOR_FUNCTIONS: set[str] = set()
|
||||
# This is a List rather than just a string in order to make it easier
|
||||
# to write to it from another module.
|
||||
AUTHENTICATION_LINE: list[str] = [""]
|
||||
|
||||
helpers = ZulipTestCase()
|
||||
|
||||
|
||||
def openapi_param_value_generator(
|
||||
endpoints: list[str],
|
||||
) -> Callable[[Callable[[], dict[str, object]]], Callable[[], dict[str, object]]]:
|
||||
"""This decorator is used to register OpenAPI param value generator functions
|
||||
with endpoints. Example usage:
|
||||
|
||||
@openapi_param_value_generator(["/messages/render:post"])
|
||||
def ...
|
||||
"""
|
||||
|
||||
def wrapper(generator_func: Callable[[], dict[str, object]]) -> Callable[[], dict[str, object]]:
|
||||
@wraps(generator_func)
|
||||
def _record_calls_wrapper() -> dict[str, object]:
|
||||
CALLED_GENERATOR_FUNCTIONS.add(generator_func.__name__)
|
||||
return generator_func()
|
||||
|
||||
REGISTERED_GENERATOR_FUNCTIONS.add(generator_func.__name__)
|
||||
for endpoint in endpoints:
|
||||
GENERATOR_FUNCTIONS[endpoint] = _record_calls_wrapper
|
||||
|
||||
return _record_calls_wrapper
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def assert_all_helper_functions_called() -> None:
|
||||
"""Throws an exception if any registered helpers were not called by tests"""
|
||||
if REGISTERED_GENERATOR_FUNCTIONS == CALLED_GENERATOR_FUNCTIONS:
|
||||
return
|
||||
|
||||
uncalled_functions = str(REGISTERED_GENERATOR_FUNCTIONS - CALLED_GENERATOR_FUNCTIONS)
|
||||
|
||||
raise Exception(f"Registered curl API generators were not called: {uncalled_functions}")
|
||||
|
||||
|
||||
def patch_openapi_example_values(
|
||||
entry: str,
|
||||
parameters: list[Parameter],
|
||||
request_body: dict[str, Any] | None = None,
|
||||
) -> tuple[list[Parameter], dict[str, object] | None]:
|
||||
if entry not in GENERATOR_FUNCTIONS:
|
||||
return parameters, request_body
|
||||
func = GENERATOR_FUNCTIONS[entry]
|
||||
realm_example_values: dict[str, object] = func()
|
||||
|
||||
for parameter in parameters:
|
||||
if parameter.name in realm_example_values:
|
||||
parameter.example = realm_example_values[parameter.name]
|
||||
|
||||
if request_body is not None and "multipart/form-data" in (content := request_body["content"]):
|
||||
properties = content["multipart/form-data"]["schema"]["properties"]
|
||||
for key, property in properties.items():
|
||||
if key in realm_example_values:
|
||||
property["example"] = realm_example_values[key]
|
||||
return parameters, request_body
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/fetch_api_key:post"])
|
||||
def fetch_api_key() -> dict[str, object]:
|
||||
email = helpers.example_email("iago")
|
||||
password = initial_password(email)
|
||||
|
||||
return {
|
||||
"username": email,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(
|
||||
[
|
||||
"/messages/{message_id}:get",
|
||||
"/messages/{message_id}/history:get",
|
||||
"/messages/{message_id}:patch",
|
||||
"/messages/{message_id}:delete",
|
||||
"/messages/{message_id}/reactions:post",
|
||||
]
|
||||
)
|
||||
def iago_message_id() -> dict[str, object]:
|
||||
iago = helpers.example_user("iago")
|
||||
helpers.subscribe(iago, "Denmark")
|
||||
return {
|
||||
"message_id": helpers.send_stream_message(iago, "Denmark"),
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/messages/{message_id}/reactions:delete"])
|
||||
def add_emoji_to_message() -> dict[str, object]:
|
||||
user_profile = helpers.example_user("iago")
|
||||
|
||||
# Get a message ID by calling the message generator
|
||||
message_id = cast(int, iago_message_id()["message_id"])
|
||||
|
||||
emoji_name = "octopus"
|
||||
emoji_code = "1f419"
|
||||
reaction_type = "unicode_emoji"
|
||||
|
||||
message = Message.objects.select_related(*Message.DEFAULT_SELECT_RELATED).get(id=message_id)
|
||||
do_add_reaction(user_profile, message, emoji_name, emoji_code, reaction_type)
|
||||
|
||||
return {
|
||||
"message_id": message_id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/messages/flags:post"])
|
||||
def update_flags_message_ids() -> dict[str, object]:
|
||||
stream_name = "Venice"
|
||||
helpers.subscribe(helpers.example_user("iago"), stream_name)
|
||||
|
||||
messages = [
|
||||
helpers.send_stream_message(helpers.example_user("iago"), stream_name) for _ in range(3)
|
||||
]
|
||||
return {
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/mark_stream_as_read:post", "/users/me/{stream_id}/topics:get"])
|
||||
def get_venice_stream_id() -> dict[str, object]:
|
||||
return {
|
||||
"stream_id": helpers.get_stream_id("Venice"),
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/streams/{stream_id}:patch"])
|
||||
def update_stream() -> dict[str, object]:
|
||||
stream = helpers.subscribe(helpers.example_user("iago"), "temp_stream 1")
|
||||
return {
|
||||
"stream_id": stream.id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/streams/{stream_id}:delete"])
|
||||
def create_temp_stream_and_get_id() -> dict[str, object]:
|
||||
stream = helpers.subscribe(helpers.example_user("iago"), "temp_stream 2")
|
||||
return {
|
||||
"stream_id": stream.id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/mark_topic_as_read:post"])
|
||||
def get_denmark_stream_id_and_topic() -> dict[str, object]:
|
||||
stream_name = "Denmark"
|
||||
topic_name = "Tivoli Gardens"
|
||||
|
||||
helpers.subscribe(helpers.example_user("iago"), stream_name)
|
||||
helpers.send_stream_message(helpers.example_user("hamlet"), stream_name, topic_name=topic_name)
|
||||
|
||||
return {
|
||||
"stream_id": helpers.get_stream_id(stream_name),
|
||||
"topic_name": topic_name,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/me/subscriptions/properties:post"])
|
||||
def update_subscription_data() -> dict[str, object]:
|
||||
profile = helpers.example_user("iago")
|
||||
helpers.subscribe(profile, "Verona")
|
||||
helpers.subscribe(profile, "social")
|
||||
return {
|
||||
"subscription_data": [
|
||||
{"stream_id": helpers.get_stream_id("Verona"), "property": "pin_to_top", "value": True},
|
||||
{"stream_id": helpers.get_stream_id("social"), "property": "color", "value": "#f00f00"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/me/subscriptions:delete"])
|
||||
def delete_subscription_data() -> dict[str, object]:
|
||||
iago = helpers.example_user("iago")
|
||||
zoe = helpers.example_user("ZOE")
|
||||
helpers.subscribe(iago, "Verona")
|
||||
helpers.subscribe(iago, "social")
|
||||
helpers.subscribe(zoe, "Verona")
|
||||
helpers.subscribe(zoe, "social")
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/events:get"])
|
||||
def get_events() -> dict[str, object]:
|
||||
profile = helpers.example_user("iago")
|
||||
helpers.subscribe(profile, "Verona")
|
||||
client = Client.objects.create(name="curl-test-client-1")
|
||||
response = do_events_register(
|
||||
profile, profile.realm, client, event_types=["message", "realm_emoji"]
|
||||
)
|
||||
helpers.send_stream_message(helpers.example_user("hamlet"), "Verona")
|
||||
return {
|
||||
"queue_id": response["queue_id"],
|
||||
"last_event_id": response["last_event_id"],
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/events:delete"])
|
||||
def delete_event_queue() -> dict[str, object]:
|
||||
profile = helpers.example_user("iago")
|
||||
client = Client.objects.create(name="curl-test-client-2")
|
||||
response = do_events_register(profile, profile.realm, client, event_types=["message"])
|
||||
return {
|
||||
"queue_id": response["queue_id"],
|
||||
"last_event_id": response["last_event_id"],
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/{user_id_or_email}/presence:get"])
|
||||
def get_user_presence() -> dict[str, object]:
|
||||
iago = helpers.example_user("iago")
|
||||
client = Client.objects.create(name="curl-test-client-3")
|
||||
update_user_presence(iago, client, timezone_now(), UserPresence.LEGACY_STATUS_ACTIVE_INT, False)
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users:post"])
|
||||
def create_user() -> dict[str, object]:
|
||||
return {
|
||||
"email": helpers.nonreg_email("test"),
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/{email]:patch", "/users/{user_id}:patch"])
|
||||
def new_email_value() -> dict[str, object]:
|
||||
count = 0
|
||||
exists = True
|
||||
while exists:
|
||||
email = f"new{count}@zulip.com"
|
||||
exists = UserProfile.objects.filter(delivery_email=email).exists()
|
||||
count += 1
|
||||
return {
|
||||
"new_email": email,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/user_groups/create:post"])
|
||||
def create_user_group_data() -> dict[str, object]:
|
||||
return {
|
||||
"members": [helpers.example_user("hamlet").id, helpers.example_user("othello").id],
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/user_groups/{user_group_id}:patch"])
|
||||
def get_temp_user_group_id() -> dict[str, object]:
|
||||
user_group, _ = NamedUserGroup.objects.get_or_create(
|
||||
name="temp",
|
||||
realm=get_realm("zulip"),
|
||||
can_add_members_group_id=11,
|
||||
can_join_group_id=11,
|
||||
can_leave_group_id=15,
|
||||
can_manage_group_id=11,
|
||||
can_mention_group_id=11,
|
||||
can_remove_members_group_id=11,
|
||||
realm_for_sharding=get_realm("zulip"),
|
||||
)
|
||||
return {
|
||||
"user_group_id": user_group.id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/user_groups/{user_group_id}/deactivate:post"])
|
||||
def get_temp_user_group_id_for_deactivation() -> dict[str, object]:
|
||||
user_group, _ = NamedUserGroup.objects.get_or_create(
|
||||
name="temp-deactivation",
|
||||
realm=get_realm("zulip"),
|
||||
can_add_members_group_id=11,
|
||||
can_join_group_id=11,
|
||||
can_leave_group_id=15,
|
||||
can_manage_group_id=11,
|
||||
can_mention_group_id=11,
|
||||
can_remove_members_group_id=11,
|
||||
realm_for_sharding=get_realm("zulip"),
|
||||
)
|
||||
return {
|
||||
"user_group_id": user_group.id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/filters/{filter_id}:delete"])
|
||||
def remove_realm_filters() -> dict[str, object]:
|
||||
filter_id = do_add_linkifier(
|
||||
get_realm("zulip"),
|
||||
"#(?P<id>[0-9]{2,8})",
|
||||
"https://github.com/zulip/zulip/pull/{id}",
|
||||
acting_user=None,
|
||||
)
|
||||
return {
|
||||
"filter_id": filter_id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/emoji/{emoji_name}:post", "/user_uploads:post"])
|
||||
def upload_custom_emoji() -> dict[str, object]:
|
||||
return {
|
||||
"filename": "zerver/tests/images/animated_img.gif",
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/playgrounds:post"])
|
||||
def add_realm_playground() -> dict[str, object]:
|
||||
return {
|
||||
"name": "Python2 playground",
|
||||
"pygments_language": "Python2",
|
||||
"url_template": "https://python2.example.com?code={code}",
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/playgrounds/{playground_id}:delete"])
|
||||
def remove_realm_playground() -> dict[str, object]:
|
||||
playground_id = check_add_realm_playground(
|
||||
get_realm("zulip"),
|
||||
acting_user=None,
|
||||
name="Python playground",
|
||||
pygments_language="Python",
|
||||
url_template="https://python.example.com?code={code}",
|
||||
)
|
||||
return {
|
||||
"playground_id": playground_id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/profile_fields/{field_id}:patch"])
|
||||
def update_custom_profile_field() -> dict[str, object]:
|
||||
# A dropdown field, so that the documented field_data example validates.
|
||||
field_data: ProfileFieldData = {
|
||||
"0": {"text": "Vim", "order": "1"},
|
||||
"1": {"text": "Emacs", "order": "2"},
|
||||
}
|
||||
field = try_add_realm_custom_profile_field(
|
||||
get_realm("zulip"),
|
||||
"Favorite IDE",
|
||||
CustomProfileField.DROPDOWN,
|
||||
field_data=field_data,
|
||||
)
|
||||
return {
|
||||
"field_id": field.id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/profile_fields/{field_id}:delete"])
|
||||
def delete_custom_profile_field() -> dict[str, object]:
|
||||
field = try_add_realm_custom_profile_field(
|
||||
get_realm("zulip"),
|
||||
"Field to delete",
|
||||
CustomProfileField.SHORT_TEXT,
|
||||
)
|
||||
return {
|
||||
"field_id": field.id,
|
||||
}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/{user_id}:delete"])
|
||||
def deactivate_user() -> dict[str, object]:
|
||||
user_profile = do_create_user(
|
||||
email="testuser@zulip.com",
|
||||
password=None,
|
||||
full_name="test_user",
|
||||
realm=get_realm("zulip"),
|
||||
acting_user=None,
|
||||
)
|
||||
return {"user_id": user_profile.id}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/me:delete"])
|
||||
def deactivate_own_user() -> dict[str, object]:
|
||||
test_user_email = "delete-test@zulip.com"
|
||||
deactivate_test_user = do_create_user(
|
||||
test_user_email,
|
||||
"secret",
|
||||
get_realm("zulip"),
|
||||
"Mr. Delete",
|
||||
role=200,
|
||||
acting_user=None,
|
||||
)
|
||||
realm = get_realm("zulip")
|
||||
test_user = get_user(test_user_email, realm)
|
||||
test_user_api_key = test_user.api_key
|
||||
# change authentication line to allow test_client to delete itself.
|
||||
AUTHENTICATION_LINE[0] = f"{deactivate_test_user.email}:{test_user_api_key}"
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/attachments/{attachment_id}:delete"])
|
||||
def remove_attachment() -> dict[str, object]:
|
||||
user_profile = helpers.example_user("iago")
|
||||
url = upload_message_attachment("dummy.txt", "text/plain", b"zulip!", user_profile)[0]
|
||||
attachment_id = url.replace("/user_uploads/", "").split("/")[0]
|
||||
|
||||
return {"attachment_id": attachment_id}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/channel_folders:patch"])
|
||||
def add_channel_folders() -> dict[str, object]:
|
||||
user_profile = helpers.example_user("iago")
|
||||
realm = user_profile.realm
|
||||
check_add_channel_folder(
|
||||
realm,
|
||||
"General",
|
||||
"Channel for general discussions",
|
||||
acting_user=user_profile,
|
||||
)
|
||||
check_add_channel_folder(
|
||||
realm,
|
||||
"Documentation",
|
||||
"Channels for **documentation** discussions",
|
||||
acting_user=user_profile,
|
||||
)
|
||||
check_add_channel_folder(realm, "Memes", "Channels for sharing memes", acting_user=user_profile)
|
||||
channel_folders = ChannelFolder.objects.filter(realm=realm)
|
||||
|
||||
return {"order": [folder.id for folder in channel_folders]}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/user_uploads/{realm_id_str}/{filename}:get"])
|
||||
def get_temporary_url_for_uploaded_file() -> dict[str, object]:
|
||||
realm_id = ""
|
||||
filename = ""
|
||||
user_profile = helpers.example_user("iago")
|
||||
url = upload_message_attachment("dummy.txt", "text/plain", b"zulip!", user_profile)[0]
|
||||
upload_path_parts = re.match(r"/user_uploads/(\d+)/(.*)", url)
|
||||
if upload_path_parts:
|
||||
realm_id = upload_path_parts[1]
|
||||
filename = upload_path_parts[2]
|
||||
return {"realm_id_str": realm_id, "filename": filename}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/users/me/api_key/regenerate:post"])
|
||||
def regenerate_api_key_test_user() -> dict[str, object]:
|
||||
test_user_email = "regenerate-api-key-test@zulip.com"
|
||||
test_user = do_create_user(
|
||||
test_user_email,
|
||||
"secret",
|
||||
get_realm("zulip"),
|
||||
"Mr. Regenerate",
|
||||
role=200,
|
||||
acting_user=None,
|
||||
)
|
||||
realm = get_realm("zulip")
|
||||
test_user_api_key = get_user(test_user_email, realm).api_key
|
||||
# Change authentication line to allow test_client to regenerate its own key.
|
||||
AUTHENTICATION_LINE[0] = f"{test_user.email}:{test_user_api_key}"
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/bots/{bot_id}/api_key/regenerate:post"])
|
||||
def regenerate_bot_api_key_test() -> dict[str, object]:
|
||||
bot = UserProfile.objects.filter(is_bot=True, realm=get_realm("zulip")).first()
|
||||
assert bot is not None
|
||||
return {"bot_id": bot.id}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/thumbnail/status/{realm_id_str}/{filename}:get"])
|
||||
def check_thumbnail_status_for_uploaded_file() -> dict[str, object]:
|
||||
realm_id = ""
|
||||
filename = ""
|
||||
user_profile = helpers.example_user("iago")
|
||||
url = upload_message_attachment(
|
||||
"img.png", "image/png", read_test_image_file("img.png"), user_profile
|
||||
)[0]
|
||||
upload_path_parts = re.match(r"/user_uploads/(\d+)/(.*)", url)
|
||||
if upload_path_parts:
|
||||
realm_id = upload_path_parts[1]
|
||||
filename = upload_path_parts[2]
|
||||
return {"realm_id_str": realm_id, "filename": filename}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/domains:post"])
|
||||
def add_realm_domain_owner_auth() -> dict[str, object]:
|
||||
# This endpoint requires organization owner permissions.
|
||||
owner = helpers.example_user("desdemona")
|
||||
AUTHENTICATION_LINE[0] = f"{owner.email}:{owner.api_key}"
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/domains/{domain}:patch"])
|
||||
def patch_realm_domain_owner_auth() -> dict[str, object]:
|
||||
# This endpoint requires organization owner permissions.
|
||||
owner = helpers.example_user("desdemona")
|
||||
AUTHENTICATION_LINE[0] = f"{owner.email}:{owner.api_key}"
|
||||
|
||||
do_add_realm_domain(owner.realm, "patch-domain-example.com", False, acting_user=None)
|
||||
return {"domain": "patch-domain-example.com"}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/realm/domains/{domain}:delete"])
|
||||
def delete_realm_domain_owner_auth() -> dict[str, object]:
|
||||
# This endpoint requires organization owner permissions.
|
||||
owner = helpers.example_user("desdemona")
|
||||
AUTHENTICATION_LINE[0] = f"{owner.email}:{owner.api_key}"
|
||||
|
||||
do_add_realm_domain(owner.realm, "delete-domain-example.com", False, acting_user=None)
|
||||
return {"domain": "delete-domain-example.com"}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/bot_storage:put"])
|
||||
def update_bot_storage_bot_auth() -> dict[str, object]:
|
||||
bot = helpers.example_user("default_bot")
|
||||
AUTHENTICATION_LINE[0] = f"{bot.email}:{bot.api_key}"
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/bot_storage:get"])
|
||||
def get_bot_storage_bot_auth() -> dict[str, object]:
|
||||
bot = helpers.example_user("default_bot")
|
||||
set_bot_storage(bot, [("foo", "bar")])
|
||||
AUTHENTICATION_LINE[0] = f"{bot.email}:{bot.api_key}"
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/bot_storage:delete"])
|
||||
def remove_bot_storage_bot_auth() -> dict[str, object]:
|
||||
bot = helpers.example_user("default_bot")
|
||||
set_bot_storage(bot, [("foo", "bar")])
|
||||
AUTHENTICATION_LINE[0] = f"{bot.email}:{bot.api_key}"
|
||||
return {}
|
||||
|
||||
|
||||
@openapi_param_value_generator(["/export/realm/{export_id}:delete"])
|
||||
def delete_realm_export() -> dict[str, object]:
|
||||
export = RealmExport.objects.create(
|
||||
realm=get_realm("zulip"),
|
||||
type=RealmExport.EXPORT_PUBLIC,
|
||||
status=RealmExport.SUCCEEDED,
|
||||
date_requested=timezone_now(),
|
||||
date_started=timezone_now(),
|
||||
date_succeeded=timezone_now(),
|
||||
export_path="/dummy",
|
||||
)
|
||||
return {"export_id": export.id}
|
||||
377
zerver/openapi/javascript_examples.js
Normal file
377
zerver/openapi/javascript_examples.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/*
|
||||
Zulip's OpenAPI-based API documentation system is documented at
|
||||
https://zulip.readthedocs.io/en/latest/documentation/api.html
|
||||
|
||||
This file contains example code fenced off by comments, and is
|
||||
designed to be run as part of Zulip's test-api test suite to verify
|
||||
that the documented examples are all correct, runnable code.
|
||||
*/
|
||||
|
||||
import zulipInit from "zulip-js";
|
||||
|
||||
const examples_handler = function () {
|
||||
const config = {
|
||||
username: process.env.ZULIP_USERNAME,
|
||||
apiKey: process.env.ZULIP_API_KEY,
|
||||
realm: process.env.ZULIP_REALM,
|
||||
};
|
||||
const examples = {};
|
||||
const response_data = [];
|
||||
|
||||
const make_result_object = (example, result, count) => {
|
||||
const name = count !== undefined ? `${example.name}_${count}` : example.name;
|
||||
const [endpoint, method] = example.endpoint.split(":", 2);
|
||||
return {
|
||||
name,
|
||||
endpoint,
|
||||
method,
|
||||
status_code: example.status_code.toString(),
|
||||
result,
|
||||
};
|
||||
};
|
||||
|
||||
const generate_validation_data = async (client, example) => {
|
||||
let count = 0;
|
||||
const console = {
|
||||
log(result) {
|
||||
response_data.push(make_result_object(example, result, count));
|
||||
count += 1;
|
||||
},
|
||||
};
|
||||
await example.func(client, console);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const client = await zulipInit(config);
|
||||
|
||||
await generate_validation_data(client, examples.send_message);
|
||||
await generate_validation_data(client, examples.create_user);
|
||||
await generate_validation_data(client, examples.get_custom_emoji);
|
||||
await generate_validation_data(client, examples.delete_queue);
|
||||
await generate_validation_data(client, examples.get_messages);
|
||||
await generate_validation_data(client, examples.get_own_user);
|
||||
await generate_validation_data(client, examples.get_stream_id);
|
||||
await generate_validation_data(client, examples.get_stream_topics);
|
||||
await generate_validation_data(client, examples.get_subscriptions);
|
||||
await generate_validation_data(client, examples.get_users);
|
||||
await generate_validation_data(client, examples.register_queue);
|
||||
await generate_validation_data(client, examples.render_message);
|
||||
await generate_validation_data(client, examples.set_typing_status);
|
||||
await generate_validation_data(client, examples.add_subscriptions);
|
||||
await generate_validation_data(client, examples.remove_subscriptions);
|
||||
await generate_validation_data(client, examples.update_message_flags);
|
||||
await generate_validation_data(client, examples.update_message);
|
||||
await generate_validation_data(client, examples.get_events);
|
||||
await generate_validation_data(client, examples.get_streams);
|
||||
|
||||
console.log(JSON.stringify(response_data));
|
||||
return;
|
||||
};
|
||||
|
||||
const add_example = (name, endpoint, status_code, func) => {
|
||||
const example = {
|
||||
name,
|
||||
endpoint,
|
||||
status_code,
|
||||
func,
|
||||
};
|
||||
examples[name] = example;
|
||||
};
|
||||
|
||||
return {
|
||||
main,
|
||||
add_example,
|
||||
};
|
||||
};
|
||||
|
||||
const {main, add_example} = examples_handler();
|
||||
|
||||
const send_test_message = async (client) => {
|
||||
const params = {
|
||||
to: "Verona",
|
||||
type: "stream",
|
||||
topic: "Castle",
|
||||
// Use some random text for easier debugging if needed. We don't
|
||||
// depend on the content of these messages for the tests.
|
||||
content: `Random test message ${Math.random()}`,
|
||||
};
|
||||
const result = await client.messages.send(params);
|
||||
// Only return the message id.
|
||||
return result.id;
|
||||
};
|
||||
|
||||
// Declare all the examples below.
|
||||
|
||||
add_example("send_message", "/messages:post", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Send a channel message
|
||||
let params = {
|
||||
to: "social",
|
||||
type: "stream",
|
||||
topic: "Castle",
|
||||
content: "I come not, friends, to steal away your hearts.",
|
||||
};
|
||||
console.log(await client.messages.send(params));
|
||||
|
||||
// Send a direct message
|
||||
const user_id = 9;
|
||||
params = {
|
||||
to: [user_id],
|
||||
type: "direct",
|
||||
content: "With mirth and laughter let old wrinkles come.",
|
||||
};
|
||||
console.log(await client.messages.send(params));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("create_user", "/users:post", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
const params = {
|
||||
email: "notnewbie@zulip.com",
|
||||
password: "temp",
|
||||
full_name: "New User",
|
||||
};
|
||||
|
||||
console.log(await client.users.create(params));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_custom_emoji", "/realm/emoji:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
console.log(await client.emojis.retrieve());
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("delete_queue", "/events:delete", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Register a queue
|
||||
const queueParams = {
|
||||
event_types: ["message"],
|
||||
};
|
||||
const res = await client.queues.register(queueParams);
|
||||
|
||||
// Delete a queue
|
||||
const deregisterParams = {
|
||||
queue_id: res.queue_id,
|
||||
};
|
||||
|
||||
console.log(await client.queues.deregister(deregisterParams));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_messages", "/messages:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
const readParams = {
|
||||
anchor: "newest",
|
||||
num_before: 100,
|
||||
num_after: 0,
|
||||
narrow: [
|
||||
{operator: "sender", operand: "iago@zulip.com"},
|
||||
{operator: "channel", operand: "Verona"},
|
||||
],
|
||||
};
|
||||
|
||||
// Get the 100 last messages sent by "iago@zulip.com" to the channel "Verona"
|
||||
console.log(await client.messages.retrieve(readParams));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_own_user", "/users/me:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Get the profile of the user/bot that requests this endpoint,
|
||||
// which is `client` in this case:
|
||||
console.log(await client.users.me.getProfile());
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_stream_id", "/get_stream_id:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Get the ID of a given channel
|
||||
console.log(await client.streams.getStreamId("Denmark"));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example(
|
||||
"get_stream_topics",
|
||||
"/users/me/{stream_id}/topics:get",
|
||||
200,
|
||||
async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Get all the topics in channel with ID 1
|
||||
console.log(await client.streams.topics.retrieve({stream_id: 1}));
|
||||
// {code_example|end}
|
||||
},
|
||||
);
|
||||
|
||||
add_example("get_subscriptions", "/users/me/subscriptions:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Get all channels that the user is subscribed to
|
||||
console.log(await client.streams.subscriptions.retrieve());
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_users", "/users:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Get all users in the realm
|
||||
console.log(await client.users.retrieve());
|
||||
|
||||
// You may pass the `client_gravatar` query parameter as follows:
|
||||
console.log(await client.users.retrieve({client_gravatar: true}));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("register_queue", "/register:post", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Register a queue
|
||||
const params = {
|
||||
event_types: ["message"],
|
||||
};
|
||||
|
||||
console.log(await client.queues.register(params));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("render_message", "/messages/render:post", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Render a message
|
||||
const params = {
|
||||
content: "**foo**",
|
||||
};
|
||||
|
||||
console.log(await client.messages.render(params));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("set_typing_status", "/typing:post", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
const user_id1 = 9;
|
||||
const user_id2 = 10;
|
||||
|
||||
const typingParams = {
|
||||
op: "start",
|
||||
to: [user_id1, user_id2],
|
||||
};
|
||||
|
||||
// The user has started typing in the group direct message
|
||||
// with Iago and Polonius
|
||||
console.log(await client.typing.send(typingParams));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("add_subscriptions", "/users/me/subscriptions:post", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Subscribe to the channels "Verona" and "Denmark"
|
||||
const meParams = {
|
||||
subscriptions: JSON.stringify([{name: "Verona"}, {name: "Denmark"}]),
|
||||
};
|
||||
console.log(await client.users.me.subscriptions.add(meParams));
|
||||
|
||||
// To subscribe another user to a channel, you may pass in
|
||||
// the `principals` parameter, like so:
|
||||
const user_id = 7;
|
||||
const anotherUserParams = {
|
||||
subscriptions: JSON.stringify([{name: "Verona"}, {name: "Denmark"}]),
|
||||
principals: JSON.stringify([user_id]),
|
||||
};
|
||||
console.log(await client.users.me.subscriptions.add(anotherUserParams));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example(
|
||||
"remove_subscriptions",
|
||||
"/users/me/subscriptions:delete",
|
||||
200,
|
||||
async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Unsubscribe from the channel "Denmark"
|
||||
const meParams = {
|
||||
subscriptions: JSON.stringify(["Denmark"]),
|
||||
};
|
||||
console.log(await client.users.me.subscriptions.remove(meParams));
|
||||
|
||||
const user_id = 7;
|
||||
// Unsubscribe Zoe from the channel "Denmark"
|
||||
const zoeParams = {
|
||||
subscriptions: JSON.stringify(["Denmark"]),
|
||||
principals: JSON.stringify([user_id]),
|
||||
};
|
||||
console.log(await client.users.me.subscriptions.remove(zoeParams));
|
||||
// {code_example|end}
|
||||
},
|
||||
);
|
||||
|
||||
add_example("update_message_flags", "/messages/flags:post", 200, async (client, console) => {
|
||||
// Send 3 messages to run this example on
|
||||
const message_ids = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
message_ids.push(await send_test_message(client));
|
||||
}
|
||||
|
||||
// {code_example|start}
|
||||
// Add the "read" flag to the messages with IDs in "message_ids"
|
||||
const addflag = {
|
||||
messages: message_ids,
|
||||
flag: "read",
|
||||
};
|
||||
console.log(await client.messages.flags.add(addflag));
|
||||
|
||||
// Remove the "starred" flag from the messages with IDs in "message_ids"
|
||||
const removeflag = {
|
||||
messages: message_ids,
|
||||
flag: "starred",
|
||||
};
|
||||
console.log(await client.messages.flags.remove(removeflag));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("update_message", "/messages/{message_id}:patch", 200, async (client, console) => {
|
||||
const request = {
|
||||
to: "Denmark",
|
||||
type: "stream",
|
||||
topic: "Castle",
|
||||
content: "I come not, friends, to steal away your hearts.",
|
||||
};
|
||||
const result = await client.messages.send(request);
|
||||
const message_id = result.id;
|
||||
|
||||
// {code_example|start}
|
||||
// Update a message with the given "message_id"
|
||||
const params = {
|
||||
message_id,
|
||||
content: "New Content",
|
||||
};
|
||||
|
||||
console.log(await client.messages.update(params));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_events", "/events:get", 200, async (client, console) => {
|
||||
// Register queue to receive messages for user.
|
||||
const queueParams = {
|
||||
event_types: ["message"],
|
||||
};
|
||||
const res = await client.queues.register(queueParams);
|
||||
const queue_id = res.queue_id;
|
||||
// For setup, we send a message to ensure there are events in the
|
||||
// queue; this lets the automated tests complete quickly.
|
||||
await send_test_message(client);
|
||||
|
||||
// {code_example|start}
|
||||
// Retrieve events from a queue with given "queue_id"
|
||||
const eventParams = {
|
||||
queue_id,
|
||||
last_event_id: -1,
|
||||
};
|
||||
|
||||
console.log(await client.events.retrieve(eventParams));
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
add_example("get_streams", "/streams:get", 200, async (client, console) => {
|
||||
// {code_example|start}
|
||||
// Get all channels that the user has access to
|
||||
console.log(await client.streams.retrieve());
|
||||
// {code_example|end}
|
||||
});
|
||||
|
||||
main();
|
||||
36
zerver/openapi/javascript_examples.py
Normal file
36
zerver/openapi/javascript_examples.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Zulip's OpenAPI-based API documentation system is documented at
|
||||
# https://zulip.readthedocs.io/en/latest/documentation/api.html
|
||||
#
|
||||
# This Python file wraps the test suite for Zulip's JavaScript API
|
||||
# examples and validates the responses against our OpenAPI definitions.
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from zulip import Client
|
||||
|
||||
from zerver.openapi.openapi import validate_against_openapi_schema
|
||||
|
||||
|
||||
def test_js_bindings(client: Client) -> None:
|
||||
os.environ["ZULIP_USERNAME"] = client.email
|
||||
os.environ["ZULIP_API_KEY"] = client.api_key
|
||||
os.environ["ZULIP_REALM"] = client.base_url.removesuffix("/api/")
|
||||
|
||||
output = subprocess.check_output(
|
||||
args=["node", "--unhandled-rejections=strict", "zerver/openapi/javascript_examples.js"],
|
||||
text=True,
|
||||
)
|
||||
endpoint_responses = json.loads(output)
|
||||
|
||||
for response_data in endpoint_responses:
|
||||
print(f"Testing javascript example: {response_data['name']} ...")
|
||||
validate_against_openapi_schema(
|
||||
response_data["result"],
|
||||
response_data["endpoint"],
|
||||
response_data["method"],
|
||||
response_data["status_code"],
|
||||
)
|
||||
|
||||
print("JavaScript examples validated.")
|
||||
568
zerver/openapi/markdown_extension.py
Normal file
568
zerver/openapi/markdown_extension.py
Normal file
@@ -0,0 +1,568 @@
|
||||
# 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,
|
||||
check_requires_owner,
|
||||
check_web_app_pending_implementation,
|
||||
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 = "EMAIL_ADDRESS"
|
||||
DEFAULT_AUTH_API_KEY = "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
|
||||
|
||||
|
||||
INSECURE_OPERATIONS = [
|
||||
"/dev_fetch_api_key:post",
|
||||
"/fetch_api_key:post",
|
||||
"/jwt/fetch_api_key:post",
|
||||
"/dev_list_users:get",
|
||||
]
|
||||
|
||||
|
||||
def add_curl_auth_credentials_note(endpoint: str, method: str) -> bool:
|
||||
operation = endpoint + ":" + method.lower()
|
||||
return operation not in INSECURE_OPERATIONS
|
||||
|
||||
|
||||
def generate_curl_example(
|
||||
endpoint: str,
|
||||
method: str,
|
||||
api_url: str,
|
||||
exclude: list[str] | None = None,
|
||||
include: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
operation = endpoint + ":" + method.lower()
|
||||
operation_entry = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
||||
global_security = openapi_spec.openapi()["security"]
|
||||
|
||||
lines = []
|
||||
lines.append("```curl")
|
||||
|
||||
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))
|
||||
|
||||
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:
|
||||
is_zilencer_endpoint = endpoint.startswith("/remotes/")
|
||||
auth_email = "ZULIP_ORG_ID" if is_zilencer_endpoint else DEFAULT_AUTH_EMAIL
|
||||
auth_api_key = "ZULIP_ORG_KEY" if is_zilencer_endpoint else DEFAULT_AUTH_API_KEY
|
||||
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."""
|
||||
endpoint, method = function.split(":")
|
||||
kwargs: dict[str, Any] = {}
|
||||
kwargs["api_url"] = api_url
|
||||
rendered_example = []
|
||||
if add_curl_auth_credentials_note(endpoint, method):
|
||||
rendered_example += ["{!curl-auth-credentials.md!}\n\n"]
|
||||
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-pending-web.md!}"]
|
||||
if check_web_app_pending_implementation(path, method)
|
||||
else []
|
||||
),
|
||||
*(["{!api-admin-only.md!}"] if check_requires_administrator(path, method) else []),
|
||||
*(["{!api-owner-only.md!}"] if check_requires_owner(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)
|
||||
140
zerver/openapi/merge_api_changelogs.py
Normal file
140
zerver/openapi/merge_api_changelogs.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_changelog_files_list() -> list[str]:
|
||||
dir_path = Path("api_docs/unmerged.d")
|
||||
if os.path.exists(dir_path):
|
||||
return [os.path.basename(path) for path in glob.glob(f"{dir_path}/ZF-??????.md")]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def get_unmerged_changelogs(verbose: bool = True) -> str:
|
||||
changelogs = ""
|
||||
dir_path = Path("api_docs/unmerged.d")
|
||||
changelog_files_list = get_changelog_files_list()
|
||||
if verbose:
|
||||
if changelog_files_list:
|
||||
print(f"Unmerged changelog files: {changelog_files_list}")
|
||||
else:
|
||||
print("No unmerged changelog files found.")
|
||||
|
||||
for file_name in changelog_files_list:
|
||||
file_path = Path(f"{dir_path}/{file_name}")
|
||||
with open(file_path) as f:
|
||||
changelogs += f.read().strip("\n") + "\n"
|
||||
|
||||
return changelogs
|
||||
|
||||
|
||||
def get_feature_level(update_feature_level: bool = True) -> int:
|
||||
new_feature_level = None
|
||||
version_file_path = Path("version.py")
|
||||
|
||||
with open(version_file_path) as file:
|
||||
lines = file.readlines()
|
||||
|
||||
new_feature_level = None
|
||||
|
||||
with open(version_file_path, "w") as file:
|
||||
for line in lines:
|
||||
if line.startswith("API_FEATURE_LEVEL = "):
|
||||
match = re.search(r"\d+", line)
|
||||
if match:
|
||||
new_feature_level = int(match.group()) + 1
|
||||
if update_feature_level:
|
||||
file.write(f"API_FEATURE_LEVEL = {new_feature_level}\n")
|
||||
continue
|
||||
|
||||
file.write(line)
|
||||
|
||||
assert new_feature_level is not None
|
||||
if update_feature_level:
|
||||
print(f"Updated API feature level: {new_feature_level - 1} -> {new_feature_level}")
|
||||
return new_feature_level
|
||||
|
||||
|
||||
def get_current_major_version() -> str | None:
|
||||
changelog_path = Path("api_docs/changelog.md")
|
||||
with open(changelog_path) as file:
|
||||
for line in file:
|
||||
match = re.search(r"## Changes in Zulip (\d+\.\d+)", line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def merge_changelogs(changelogs: str, new_feature_level: int, update_changelog: bool = True) -> str:
|
||||
changelogs_merged = False
|
||||
changelog_path = Path("api_docs/changelog.md")
|
||||
|
||||
changelog_markdown_string = ""
|
||||
|
||||
with open(changelog_path) as file:
|
||||
lines = file.readlines()
|
||||
|
||||
changelogs_merged = False
|
||||
|
||||
with open(changelog_path, "w") as file:
|
||||
for line in lines:
|
||||
file.write(line)
|
||||
changelog_markdown_string += line
|
||||
if changelogs_merged:
|
||||
continue
|
||||
if re.fullmatch(r"## Changes in Zulip \d+\.\d+\n", line):
|
||||
changelogs_merged = True
|
||||
updates = f"\n**Feature level {new_feature_level}**\n\n{changelogs}"
|
||||
changelog_markdown_string += updates
|
||||
if update_changelog:
|
||||
file.write(updates)
|
||||
|
||||
if update_changelog:
|
||||
print(f"Changelogs merged to {changelog_path}.")
|
||||
return changelog_markdown_string
|
||||
|
||||
|
||||
def update_feature_level_in_api_docs(new_feature_level: int) -> None:
|
||||
changelog_files_list = get_changelog_files_list()
|
||||
num_replaces = 0
|
||||
current_version = get_current_major_version()
|
||||
|
||||
# Get all the markdown files in api_docs folder along with zulip.yaml.
|
||||
api_docs_folder = Path("api_docs")
|
||||
api_docs_paths = list(api_docs_folder.glob("*.md"))
|
||||
api_docs_paths.append(Path("zerver/openapi/zulip.yaml"))
|
||||
|
||||
for api_docs_path in api_docs_paths:
|
||||
with open(api_docs_path) as file:
|
||||
lines = file.readlines()
|
||||
|
||||
num_replaces = 0
|
||||
|
||||
with open(api_docs_path, "w") as file:
|
||||
for line in lines:
|
||||
old_line = line
|
||||
for file_name in changelog_files_list:
|
||||
temporary_feature_level = file_name[: -len(".md")]
|
||||
|
||||
pattern = rf"Zulip \d+\.\d+ \(feature level {temporary_feature_level}\)"
|
||||
replacement = f"Zulip {current_version} (feature level {new_feature_level})"
|
||||
line = re.sub(pattern, replacement, line)
|
||||
|
||||
if old_line != line:
|
||||
num_replaces += 1
|
||||
|
||||
file.write(line)
|
||||
|
||||
if num_replaces:
|
||||
print(f"Updated {api_docs_path}; {num_replaces} replaces were made.")
|
||||
|
||||
|
||||
def remove_unmerged_changelog_files() -> None:
|
||||
changelog_files_list = get_changelog_files_list()
|
||||
for file_name in changelog_files_list:
|
||||
os.remove(Path(f"api_docs/unmerged.d/{file_name}"))
|
||||
|
||||
if changelog_files_list:
|
||||
print("Removed all the unmerged changelog files.")
|
||||
631
zerver/openapi/openapi.py
Normal file
631
zerver/openapi/openapi.py
Normal file
@@ -0,0 +1,631 @@
|
||||
# Zulip's OpenAPI-based API documentation system is documented at
|
||||
# https://zulip.readthedocs.io/en/latest/documentation/api.html
|
||||
#
|
||||
# This file contains helper functions to interact with the OpenAPI
|
||||
# definitions and validate that Zulip's implementation matches what is
|
||||
# described in our documentation.
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
|
||||
import orjson
|
||||
from openapi_core import OpenAPI
|
||||
from openapi_core.protocols import Request, Response
|
||||
from openapi_core.testing import MockRequest, MockResponse
|
||||
from openapi_core.validation.exceptions import ValidationError as OpenAPIValidationError
|
||||
from pydantic import BaseModel
|
||||
|
||||
OPENAPI_SPEC_PATH = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "../openapi/zulip.yaml")
|
||||
)
|
||||
|
||||
# A list of endpoint-methods such that the endpoint
|
||||
# has documentation but not with this particular method.
|
||||
EXCLUDE_UNDOCUMENTED_ENDPOINTS = {
|
||||
("/users", "patch"),
|
||||
}
|
||||
# Consists of endpoints with some documentation remaining.
|
||||
# These are skipped but return true as the validator cannot exclude objects
|
||||
EXCLUDE_DOCUMENTED_ENDPOINTS: set[tuple[str, str]] = set()
|
||||
|
||||
|
||||
# Most of our code expects allOf to be preprocessed away because that is what
|
||||
# yamole did. Its algorithm for doing so is not standards compliant, but we
|
||||
# replicate it here.
|
||||
def naively_merge(a: dict[str, object], b: dict[str, object]) -> dict[str, object]:
|
||||
ret: dict[str, object] = a.copy()
|
||||
for key, b_value in b.items():
|
||||
if key == "example" or key not in ret:
|
||||
ret[key] = b_value
|
||||
continue
|
||||
a_value = ret[key]
|
||||
if isinstance(b_value, list):
|
||||
assert isinstance(a_value, list)
|
||||
ret[key] = a_value + b_value
|
||||
elif isinstance(b_value, dict):
|
||||
assert isinstance(a_value, dict)
|
||||
ret[key] = naively_merge(a_value, b_value)
|
||||
return ret
|
||||
|
||||
|
||||
def naively_merge_allOf(obj: object) -> object:
|
||||
if isinstance(obj, dict):
|
||||
return naively_merge_allOf_dict(obj)
|
||||
elif isinstance(obj, list):
|
||||
return list(map(naively_merge_allOf, obj))
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
def naively_merge_allOf_dict(obj: dict[str, object]) -> dict[str, object]:
|
||||
if "allOf" in obj:
|
||||
ret = obj.copy()
|
||||
subschemas = ret.pop("allOf")
|
||||
ret = naively_merge_allOf_dict(ret)
|
||||
assert isinstance(subschemas, list)
|
||||
for subschema in subschemas:
|
||||
assert isinstance(subschema, dict)
|
||||
ret = naively_merge(ret, naively_merge_allOf_dict(subschema))
|
||||
return ret
|
||||
return {key: naively_merge_allOf(value) for key, value in obj.items()}
|
||||
|
||||
|
||||
class OpenAPISpec:
|
||||
def __init__(self, openapi_path: str) -> None:
|
||||
self.openapi_path = openapi_path
|
||||
self.mtime: float | None = None
|
||||
self._openapi: dict[str, Any] = {}
|
||||
self._endpoints_dict: dict[str, str] = {}
|
||||
self._spec: OpenAPI | None = None
|
||||
|
||||
def check_reload(self) -> None:
|
||||
# Because importing yaml takes significant time, and we only
|
||||
# use python-yaml for our API docs, importing it lazily here
|
||||
# is a significant optimization to `manage.py` startup.
|
||||
#
|
||||
# There is a bit of a race here...we may have two processes
|
||||
# accessing this module level object and both trying to
|
||||
# populate self.data at the same time. Hopefully this will
|
||||
# only cause some extra processing at startup and not data
|
||||
# corruption.
|
||||
|
||||
import yaml
|
||||
from jsonref import JsonRef
|
||||
|
||||
with open(self.openapi_path) as f:
|
||||
mtime = os.fstat(f.fileno()).st_mtime
|
||||
# Using == rather than >= to cover the corner case of users placing an
|
||||
# earlier version than the current one
|
||||
if self.mtime == mtime:
|
||||
return
|
||||
|
||||
openapi = yaml.load(f, Loader=yaml.CSafeLoader)
|
||||
|
||||
spec = OpenAPI.from_dict(openapi)
|
||||
self._spec = spec
|
||||
self._openapi = naively_merge_allOf_dict(JsonRef.replace_refs(openapi))
|
||||
self.create_endpoints_dict()
|
||||
self.mtime = mtime
|
||||
|
||||
def create_endpoints_dict(self) -> None:
|
||||
# Algorithm description:
|
||||
# We have 2 types of endpoints
|
||||
# 1.with path arguments 2. without path arguments
|
||||
# In validate_against_openapi_schema we directly check
|
||||
# if we have a without path endpoint, since it does not
|
||||
# require regex. Hence they are not part of the regex dict
|
||||
# and now we are left with only:
|
||||
# endpoint with path arguments.
|
||||
# Now for this case, the regex has been created carefully,
|
||||
# numeric arguments are matched with [0-9] only and
|
||||
# emails are matched with their regex. This is why there are zero
|
||||
# collisions. Hence if this regex matches
|
||||
# an incorrect endpoint then there is some backend problem.
|
||||
# For example if we have users/{name}/presence then it will
|
||||
# conflict with users/me/presence even in the backend.
|
||||
# Care should be taken though that if we have special strings
|
||||
# such as email they must be substituted with proper regex.
|
||||
|
||||
email_regex = r"([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})"
|
||||
self._endpoints_dict = {}
|
||||
for endpoint in self._openapi["paths"]:
|
||||
if "{" not in endpoint:
|
||||
continue
|
||||
path_regex = "^" + endpoint + "$"
|
||||
# Numeric arguments have id at their end
|
||||
# so find such arguments and replace them with numeric
|
||||
# regex
|
||||
path_regex = re.sub(r"{[^}]*id}", r"[0-9]*", path_regex)
|
||||
# Email arguments end with email
|
||||
path_regex = re.sub(r"{[^}]*email}", email_regex, path_regex)
|
||||
# All other types of arguments are supposed to be
|
||||
# all-encompassing string.
|
||||
path_regex = re.sub(r"{[^}]*}", r"[^\/]*", path_regex)
|
||||
path_regex = path_regex.replace(r"/", r"\/")
|
||||
self._endpoints_dict[path_regex] = endpoint
|
||||
|
||||
def openapi(self) -> dict[str, Any]:
|
||||
"""Reload the OpenAPI file if it has been modified after the last time
|
||||
it was read, and then return the parsed data.
|
||||
"""
|
||||
self.check_reload()
|
||||
assert len(self._openapi) > 0
|
||||
return self._openapi
|
||||
|
||||
def endpoints_dict(self) -> dict[str, str]:
|
||||
"""Reload the OpenAPI file if it has been modified after the last time
|
||||
it was read, and then return the parsed data.
|
||||
"""
|
||||
self.check_reload()
|
||||
assert len(self._endpoints_dict) > 0
|
||||
return self._endpoints_dict
|
||||
|
||||
def spec(self) -> OpenAPI:
|
||||
"""Reload the OpenAPI file if it has been modified after the last time
|
||||
it was read, and then return the openapi_core validator object. Similar
|
||||
to preceding functions. Used for proper access to OpenAPI objects.
|
||||
"""
|
||||
self.check_reload()
|
||||
assert self._spec is not None
|
||||
return self._spec
|
||||
|
||||
|
||||
class SchemaError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
openapi_spec = OpenAPISpec(OPENAPI_SPEC_PATH)
|
||||
|
||||
|
||||
def get_schema(endpoint: str, method: str, status_code: str) -> dict[str, Any]:
|
||||
if len(status_code) == 3 and (
|
||||
"oneOf"
|
||||
in openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
||||
"content"
|
||||
]["application/json"]["schema"]
|
||||
):
|
||||
# Currently at places where multiple schemas are defined they only
|
||||
# differ in example so either can be used.
|
||||
status_code += "_0"
|
||||
if len(status_code) == 3:
|
||||
schema = openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][
|
||||
status_code
|
||||
]["content"]["application/json"]["schema"]
|
||||
return schema
|
||||
else:
|
||||
subschema_index = int(status_code[4])
|
||||
status_code = status_code[0:3]
|
||||
schema = openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][
|
||||
status_code
|
||||
]["content"]["application/json"]["schema"]["oneOf"][subschema_index]
|
||||
return schema
|
||||
|
||||
|
||||
def get_openapi_fixture(
|
||||
endpoint: str, method: str, status_code: str = "200"
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch a fixture from the full spec object."""
|
||||
if "example" not in get_schema(endpoint, method, status_code):
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
||||
"content"
|
||||
]["application/json"]["examples"].values()
|
||||
return [
|
||||
{
|
||||
"description": get_schema(endpoint, method, status_code)["description"],
|
||||
"value": get_schema(endpoint, method, status_code)["example"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def get_curl_include_exclude(endpoint: str, method: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all the kinds of parameters required for curl examples."""
|
||||
if (
|
||||
"x-curl-examples-parameters"
|
||||
not in openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
||||
):
|
||||
return [{"type": "exclude", "parameters": {"enum": [""]}}]
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()]["x-curl-examples-parameters"][
|
||||
"oneOf"
|
||||
]
|
||||
|
||||
|
||||
def check_web_app_pending_implementation(endpoint: str, method: str) -> bool:
|
||||
"""Fetch if the endpoint has note about web application implementation."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
||||
"x-pending-web-implementation", False
|
||||
)
|
||||
|
||||
|
||||
def check_requires_administrator(endpoint: str, method: str) -> bool:
|
||||
"""Fetch if the endpoint requires admin config."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
||||
"x-requires-administrator", False
|
||||
)
|
||||
|
||||
|
||||
def check_requires_owner(endpoint: str, method: str) -> bool:
|
||||
"""Fetch if the endpoint requires owner config."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get("x-requires-owner", False)
|
||||
|
||||
|
||||
def check_additional_imports(endpoint: str, method: str) -> list[str] | None:
|
||||
"""Fetch the additional imports required for an endpoint."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
||||
"x-python-examples-extra-imports", None
|
||||
)
|
||||
|
||||
|
||||
def get_responses_description(endpoint: str, method: str) -> str:
|
||||
"""Fetch responses description of an endpoint."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
||||
"x-response-description", ""
|
||||
)
|
||||
|
||||
|
||||
def get_parameters_description(endpoint: str, method: str) -> str:
|
||||
"""Fetch parameters description of an endpoint."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()].get(
|
||||
"x-parameter-description", ""
|
||||
)
|
||||
|
||||
|
||||
def generate_openapi_fixture(endpoint: str, method: str) -> list[str]:
|
||||
"""Generate fixture to be rendered"""
|
||||
fixture = []
|
||||
for status_code in sorted(
|
||||
openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"]
|
||||
):
|
||||
if (
|
||||
"oneOf"
|
||||
in openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
||||
"content"
|
||||
]["application/json"]["schema"]
|
||||
):
|
||||
subschema_count = len(
|
||||
openapi_spec.openapi()["paths"][endpoint][method.lower()]["responses"][status_code][
|
||||
"content"
|
||||
]["application/json"]["schema"]["oneOf"]
|
||||
)
|
||||
else:
|
||||
subschema_count = 1
|
||||
for subschema_index in range(subschema_count):
|
||||
if subschema_count != 1:
|
||||
subschema_status_code = status_code + "_" + str(subschema_index)
|
||||
else:
|
||||
subschema_status_code = status_code
|
||||
fixture_dict = get_openapi_fixture(endpoint, method, subschema_status_code)
|
||||
for example in fixture_dict:
|
||||
fixture_json = json.dumps(
|
||||
example["value"], indent=4, sort_keys=True, separators=(",", ": ")
|
||||
)
|
||||
if "description" in example:
|
||||
fixture.extend(example["description"].strip().splitlines())
|
||||
fixture.append("``` json")
|
||||
fixture.extend(fixture_json.splitlines())
|
||||
fixture.append("```")
|
||||
return fixture
|
||||
|
||||
|
||||
def get_openapi_description(endpoint: str, method: str) -> str:
|
||||
"""Fetch a description from the full spec object."""
|
||||
endpoint_documentation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
||||
endpoint_description = endpoint_documentation["description"]
|
||||
check_deprecated_consistency(
|
||||
endpoint_documentation.get("deprecated", False), endpoint_description
|
||||
)
|
||||
return endpoint_description
|
||||
|
||||
|
||||
def get_openapi_summary(endpoint: str, method: str) -> str:
|
||||
"""Fetch a summary from the full spec object."""
|
||||
return openapi_spec.openapi()["paths"][endpoint][method.lower()]["summary"]
|
||||
|
||||
|
||||
def get_endpoint_from_operationid(operationid: str) -> tuple[str, str]:
|
||||
for endpoint in openapi_spec.openapi()["paths"]:
|
||||
for method in openapi_spec.openapi()["paths"][endpoint]:
|
||||
operationId = openapi_spec.openapi()["paths"][endpoint][method].get("operationId")
|
||||
if operationId == operationid:
|
||||
return (endpoint, method)
|
||||
raise AssertionError("No such page exists in OpenAPI data.")
|
||||
|
||||
|
||||
def get_openapi_paths() -> set[str]:
|
||||
return set(openapi_spec.openapi()["paths"].keys())
|
||||
|
||||
|
||||
NO_EXAMPLE = object()
|
||||
|
||||
|
||||
class Parameter(BaseModel):
|
||||
kind: Literal["query", "path", "formData"]
|
||||
name: str
|
||||
description: str
|
||||
json_encoded: bool
|
||||
value_schema: dict[str, Any]
|
||||
example: object
|
||||
required: bool
|
||||
deprecated: bool
|
||||
|
||||
|
||||
def get_openapi_parameters(
|
||||
endpoint: str, method: str, include_url_parameters: bool = True
|
||||
) -> list[Parameter]:
|
||||
operation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
||||
parameters = []
|
||||
|
||||
# We do a `.get()` for this last bit to distinguish documented
|
||||
# endpoints with no parameters (empty list) from undocumented
|
||||
# endpoints (KeyError exception).
|
||||
for parameter in operation.get("parameters", []):
|
||||
# Also, we skip parameters defined in the URL.
|
||||
if not include_url_parameters and parameter["in"] == "path":
|
||||
continue
|
||||
|
||||
json_encoded = "content" in parameter
|
||||
if json_encoded:
|
||||
schema = parameter["content"]["application/json"]["schema"]
|
||||
else:
|
||||
schema = parameter["schema"]
|
||||
|
||||
if "example" in parameter:
|
||||
example = parameter["example"]
|
||||
elif json_encoded and "example" in parameter["content"]["application/json"]:
|
||||
example = parameter["content"]["application/json"]["example"]
|
||||
else:
|
||||
example = schema.get("example", NO_EXAMPLE)
|
||||
|
||||
parameters.append(
|
||||
Parameter(
|
||||
kind=parameter["in"],
|
||||
name=parameter["name"],
|
||||
description=parameter["description"],
|
||||
json_encoded=json_encoded,
|
||||
value_schema=schema,
|
||||
example=example,
|
||||
required=parameter.get("required", False),
|
||||
deprecated=parameter.get("deprecated", False),
|
||||
)
|
||||
)
|
||||
|
||||
if "requestBody" in operation and "application/x-www-form-urlencoded" in (
|
||||
content := operation["requestBody"]["content"]
|
||||
):
|
||||
media_type = content["application/x-www-form-urlencoded"]
|
||||
required = media_type["schema"].get("required", [])
|
||||
for key, schema in media_type["schema"]["properties"].items():
|
||||
json_encoded = (
|
||||
"encoding" in media_type
|
||||
and key in (encodings := media_type["encoding"])
|
||||
and encodings[key].get("contentType") == "application/json"
|
||||
) or schema.get("type") == "object"
|
||||
|
||||
parameters.append(
|
||||
Parameter(
|
||||
kind="formData",
|
||||
name=key,
|
||||
description=schema["description"],
|
||||
json_encoded=json_encoded,
|
||||
value_schema=schema,
|
||||
example=schema.get("example"),
|
||||
required=key in required,
|
||||
deprecated=schema.get("deprecated", False),
|
||||
)
|
||||
)
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
def get_openapi_return_values(endpoint: str, method: str) -> dict[str, Any]:
|
||||
operation = openapi_spec.openapi()["paths"][endpoint][method.lower()]
|
||||
schema = operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
# We do not currently have documented endpoints that have multiple schemas
|
||||
# ("oneOf", "anyOf", "allOf") for success ("200") responses. If this changes,
|
||||
# then the assertion below will need to be removed, and this function updated
|
||||
# so that endpoint responses will be rendered as expected.
|
||||
assert "properties" in schema
|
||||
return schema["properties"]
|
||||
|
||||
|
||||
def find_openapi_endpoint(path: str) -> str | None:
|
||||
for path_regex, endpoint in openapi_spec.endpoints_dict().items():
|
||||
matches = re.match(path_regex, path)
|
||||
if matches:
|
||||
return endpoint
|
||||
return None
|
||||
|
||||
|
||||
def validate_against_openapi_schema(
|
||||
content: dict[str, Any], path: str, method: str, status_code: str
|
||||
) -> bool:
|
||||
mock_request = MockRequest("http://localhost:9991/", method, "/api/v1" + path)
|
||||
mock_response = MockResponse(
|
||||
orjson.dumps(content),
|
||||
status_code=int(status_code),
|
||||
)
|
||||
return validate_test_response(mock_request, mock_response)
|
||||
|
||||
|
||||
def validate_test_response(request: Request, response: Response) -> bool:
|
||||
"""Compare a "content" dict with the defined schema for a specific method
|
||||
in an endpoint. Return true if validated and false if skipped.
|
||||
"""
|
||||
|
||||
if request.path.startswith("/json/"):
|
||||
path = request.path.removeprefix("/json")
|
||||
elif request.path.startswith("/api/v1/"):
|
||||
path = request.path.removeprefix("/api/v1")
|
||||
else:
|
||||
return False
|
||||
assert request.method is not None
|
||||
method = request.method.lower()
|
||||
status_code = str(response.status_code)
|
||||
|
||||
# This first set of checks are primarily training wheels that we
|
||||
# hope to eliminate over time as we improve our API documentation.
|
||||
|
||||
if path not in openapi_spec.openapi()["paths"]:
|
||||
endpoint = find_openapi_endpoint(path)
|
||||
# If it doesn't match it hasn't been documented yet.
|
||||
if endpoint is None:
|
||||
return False
|
||||
else:
|
||||
endpoint = path
|
||||
# Excluded endpoint/methods
|
||||
if (endpoint, method) in EXCLUDE_UNDOCUMENTED_ENDPOINTS:
|
||||
return False
|
||||
# Return true for endpoints with only response documentation remaining
|
||||
if (endpoint, method) in EXCLUDE_DOCUMENTED_ENDPOINTS: # nocoverage
|
||||
return True
|
||||
# Code is not declared but appears in various 400 responses. If
|
||||
# common, it can be added to 400 response schema
|
||||
if status_code.startswith("4") or status_code == "502":
|
||||
# This return statement should ideally be not here. But since
|
||||
# we have not defined 400 responses for various paths this has
|
||||
# been added as all 400 have the same schema. When all 400
|
||||
# response have been defined this should be removed.
|
||||
return True
|
||||
|
||||
try:
|
||||
openapi_spec.spec().validate_response(request, response)
|
||||
except OpenAPIValidationError as error:
|
||||
message = f"Response validation error at {method} /api/v1{path} ({status_code}):"
|
||||
message += f"\n\n{type(error).__name__}: {error}"
|
||||
message += (
|
||||
"\n\nFor help debugging these errors see: "
|
||||
"https://zulip.readthedocs.io/en/latest/documentation/api.html#debugging-schema-validation-errors"
|
||||
)
|
||||
raise SchemaError(message) from None
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def validate_schema(schema: dict[str, Any]) -> None:
|
||||
"""Check if opaque objects are present in the OpenAPI spec; this is an
|
||||
important part of our policy for ensuring every detail of Zulip's
|
||||
API responses is correct.
|
||||
|
||||
This is done by checking for the presence of the
|
||||
`additionalProperties` attribute for all objects (dictionaries).
|
||||
"""
|
||||
if "oneOf" in schema:
|
||||
for subschema in schema["oneOf"]:
|
||||
validate_schema(subschema)
|
||||
elif schema["type"] == "array":
|
||||
validate_schema(schema["items"])
|
||||
elif schema["type"] == "object":
|
||||
if "additionalProperties" not in schema:
|
||||
raise SchemaError(
|
||||
"additionalProperties needs to be defined for objects to make sure they have no"
|
||||
" additional properties left to be documented."
|
||||
)
|
||||
for property_schema in schema.get("properties", {}).values():
|
||||
validate_schema(property_schema)
|
||||
if schema["additionalProperties"]:
|
||||
validate_schema(schema["additionalProperties"])
|
||||
|
||||
|
||||
def deprecated_note_in_description(description: str) -> bool:
|
||||
if "**Changes**: Deprecated" in description:
|
||||
return True
|
||||
|
||||
return "**Deprecated**" in description
|
||||
|
||||
|
||||
def check_deprecated_consistency(deprecated: bool, description: str) -> None:
|
||||
# Test to make sure deprecated parameters are marked so.
|
||||
if deprecated_note_in_description(description):
|
||||
assert deprecated, (
|
||||
f"Missing `deprecated: true` despite being described as deprecated:\n\n{description}\n"
|
||||
)
|
||||
if deprecated:
|
||||
assert deprecated_note_in_description(description), (
|
||||
f"Marked as `deprecated: true`, but changes documentation doesn't properly explain as **Deprecated** in the standard format\n\n:{description}\n"
|
||||
)
|
||||
|
||||
|
||||
# Skip those JSON endpoints whose query parameters are different from
|
||||
# their `/api/v1` counterpart. This is a legacy code issue that we
|
||||
# plan to fix by changing the implementation.
|
||||
SKIP_JSON = {
|
||||
("/fetch_api_key", "post"),
|
||||
}
|
||||
|
||||
|
||||
def validate_request(
|
||||
url: str,
|
||||
method: str,
|
||||
data: str | bytes | Mapping[str, Any],
|
||||
http_headers: dict[str, str],
|
||||
json_url: bool,
|
||||
status_code: str,
|
||||
intentionally_undocumented: bool = False,
|
||||
) -> None:
|
||||
assert isinstance(data, dict)
|
||||
mock_request = MockRequest(
|
||||
"http://localhost:9991/",
|
||||
method,
|
||||
"/api/v1" + url,
|
||||
headers=http_headers,
|
||||
args={k: str(v) for k, v in data.items()},
|
||||
)
|
||||
validate_test_request(mock_request, status_code, intentionally_undocumented)
|
||||
|
||||
|
||||
def validate_test_request(
|
||||
request: Request,
|
||||
status_code: str,
|
||||
intentionally_undocumented: bool = False,
|
||||
) -> None:
|
||||
assert request.method is not None
|
||||
method = request.method.lower()
|
||||
if request.path.startswith("/json/"):
|
||||
url = request.path.removeprefix("/json")
|
||||
# Some JSON endpoints have different parameters compared to
|
||||
# their `/api/v1` counterparts.
|
||||
if (url, method) in SKIP_JSON:
|
||||
return
|
||||
else:
|
||||
assert request.path.startswith("/api/v1/")
|
||||
url = request.path.removeprefix("/api/v1")
|
||||
|
||||
# TODO: Add support for file upload endpoints that lack the /json/
|
||||
# or /api/v1/ prefix.
|
||||
if url == "/user_uploads" or url.startswith("/realm/emoji/"):
|
||||
return
|
||||
|
||||
# Requests that do not validate against the OpenAPI spec must either:
|
||||
# * Have returned a 400 (bad request) error
|
||||
# * Have returned a 200 (success) with this request marked as intentionally
|
||||
# undocumented behavior.
|
||||
if status_code.startswith("4"):
|
||||
return
|
||||
if status_code.startswith("2") and intentionally_undocumented:
|
||||
return
|
||||
|
||||
# Now using the openapi_core APIs, validate the request schema
|
||||
# against the OpenAPI documentation.
|
||||
try:
|
||||
openapi_spec.spec().validate_request(request)
|
||||
except OpenAPIValidationError as error:
|
||||
# Show a block error message explaining the options for fixing it.
|
||||
msg = f"""
|
||||
|
||||
Error! The OpenAPI schema for {method} {url} is not consistent
|
||||
with the parameters passed in this HTTP request. Consider:
|
||||
|
||||
* Updating the OpenAPI schema defined in zerver/openapi/zulip.yaml
|
||||
* Adjusting the test to pass valid parameters. If the test
|
||||
fails due to intentionally_undocumented features, you need to pass
|
||||
`intentionally_undocumented=True` to self.client_{method.lower()} or
|
||||
self.api_{method.lower()} to document your intent.
|
||||
|
||||
See https://zulip.readthedocs.io/en/latest/documentation/api.html for help.
|
||||
|
||||
The error logged by the OpenAPI validator is below:
|
||||
{error}
|
||||
"""
|
||||
raise SchemaError(msg)
|
||||
2385
zerver/openapi/python_examples.py
Normal file
2385
zerver/openapi/python_examples.py
Normal file
File diff suppressed because it is too large
Load Diff
161
zerver/openapi/test_curl_examples.py
Normal file
161
zerver/openapi/test_curl_examples.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# Zulip's OpenAPI-based API documentation system is documented at
|
||||
# https://zulip.readthedocs.io/en/latest/documentation/api.html
|
||||
#
|
||||
# This file contains the top-level logic for testing the cURL examples
|
||||
# in Zulip's API documentation; the details are in
|
||||
# zerver.openapi.curl_param_value_generators.
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
import markdown
|
||||
from django.conf import settings
|
||||
from zulip import Client
|
||||
|
||||
from zerver.models.realms import get_realm
|
||||
from zerver.openapi import markdown_extension
|
||||
from zerver.openapi.curl_param_value_generators import (
|
||||
AUTHENTICATION_LINE,
|
||||
assert_all_helper_functions_called,
|
||||
)
|
||||
from zerver.openapi.openapi import get_endpoint_from_operationid
|
||||
|
||||
UNTESTED_GENERATED_CURL_EXAMPLES = {
|
||||
# Requires organization-specific JWT_AUTH_KEYS configuration.
|
||||
"jwt-fetch-api-key",
|
||||
# Would need push notification bouncer set up to test the
|
||||
# generated curl example for the following three endpoints.
|
||||
"e2ee-test-notify",
|
||||
"test-notify",
|
||||
"register-remote-push-device",
|
||||
# Having a message for a specific user available to test this endpoint
|
||||
# is tricky for testing.
|
||||
"delete-reminder",
|
||||
# Video call endpoints that need the third-party call provider
|
||||
# to be configured to test the curl example for the endpoint.
|
||||
"create-constructor-groups-video-call",
|
||||
"create-nextcloud-talk-video-call",
|
||||
"create-webex-video-call",
|
||||
# Successfully running this example would deactivate the "zulip"
|
||||
# realm used by the test server, breaking the curl examples that
|
||||
# run after it.
|
||||
"deactivate-realm",
|
||||
}
|
||||
|
||||
|
||||
def test_generated_curl_examples_for_success(client: Client) -> None:
|
||||
default_authentication_line = f"{client.email}:{client.api_key}"
|
||||
# A limited Markdown engine that just processes the code example syntax.
|
||||
realm = get_realm("zulip")
|
||||
md_engine = markdown.Markdown(
|
||||
extensions=[markdown_extension.makeExtension(api_url=realm.url + "/api")]
|
||||
)
|
||||
|
||||
# We run our curl tests in alphabetical order since we depend
|
||||
# on "add" tests coming before "remove" tests in some cases. We
|
||||
# should try to either avoid ordering dependencies or make them
|
||||
# very explicit.
|
||||
rest_endpoints_path = os.path.join(settings.DEPLOY_ROOT, "api_docs/include/rest-endpoints.md")
|
||||
with open(rest_endpoints_path) as f:
|
||||
rest_endpoints_raw = f.read()
|
||||
ENDPOINT_REGEXP = re.compile(r"/api/\s*(.*?)\)")
|
||||
documented_endpoints = set(re.findall(ENDPOINT_REGEXP, rest_endpoints_raw))
|
||||
endpoints_to_test = sorted(documented_endpoints.difference(UNTESTED_GENERATED_CURL_EXAMPLES))
|
||||
|
||||
for endpoint in endpoints_to_test:
|
||||
article_name = endpoint + ".md"
|
||||
file_name = os.path.join(settings.DEPLOY_ROOT, "api_docs/", article_name)
|
||||
|
||||
if os.path.exists(file_name):
|
||||
with open(file_name) as f:
|
||||
curl_commands_to_test = [
|
||||
# A typical example from the Markdown source looks like this:
|
||||
# {generate_code_example(curl)|...|...}
|
||||
line
|
||||
for line in f
|
||||
if line.startswith("{generate_code_example(curl")
|
||||
]
|
||||
else:
|
||||
# If the file doesn't exist, then it has been
|
||||
# deleted and its page is generated by the
|
||||
# template. Thus, the curl example would just
|
||||
# a single one following the template's pattern.
|
||||
endpoint_path, endpoint_method = get_endpoint_from_operationid(endpoint)
|
||||
endpoint_string = endpoint_path + ":" + endpoint_method
|
||||
command = f"{{generate_code_example(curl)|{endpoint_string}|example}}"
|
||||
curl_commands_to_test = [command]
|
||||
|
||||
for line in curl_commands_to_test:
|
||||
# To do an end-to-end test on the documentation examples
|
||||
# that will be actually shown to users, we use the
|
||||
# Markdown rendering pipeline to compute the user-facing
|
||||
# example, and then run that to test it.
|
||||
|
||||
# Set AUTHENTICATION_LINE to default_authentication_line.
|
||||
AUTHENTICATION_LINE[0] = default_authentication_line
|
||||
|
||||
curl_command_html = md_engine.convert(line.strip())
|
||||
unescaped_html = html.unescape(curl_command_html)
|
||||
curl_regex = re.compile(r"<code>curl\n(.*?)</code>", re.DOTALL)
|
||||
commands = re.findall(curl_regex, unescaped_html)
|
||||
|
||||
for curl_command_text in commands:
|
||||
curl_command_text = curl_command_text.replace(
|
||||
"EMAIL_ADDRESS:API_KEY", AUTHENTICATION_LINE[0]
|
||||
)
|
||||
|
||||
print("Testing {} ...".format(curl_command_text.split("\n")[0]))
|
||||
|
||||
# Turn the text into an arguments list.
|
||||
generated_curl_command = [x for x in shlex.split(curl_command_text) if x != "\n"]
|
||||
|
||||
response_json = None
|
||||
response = None
|
||||
try:
|
||||
# We split this across two lines so if curl fails and
|
||||
# returns non-JSON output, we'll still print it.
|
||||
response_json = subprocess.check_output(generated_curl_command, text=True)
|
||||
response = json.loads(response_json)
|
||||
assert response["result"] == "success"
|
||||
except (AssertionError, Exception):
|
||||
error_template = """
|
||||
Error verifying the success of the API documentation curl example.
|
||||
|
||||
File: {file_name}
|
||||
Line: {line}
|
||||
Curl command:
|
||||
{curl_command}
|
||||
Response:
|
||||
{response}
|
||||
|
||||
This test is designed to check each generate_code_example(curl) instance in the
|
||||
API documentation for success. If this fails then it means that the curl example
|
||||
that was generated was faulty and when tried, it resulted in an unsuccessful
|
||||
response.
|
||||
|
||||
Common reasons for why this could occur:
|
||||
1. One or more example values in zerver/openapi/zulip.yaml for this endpoint
|
||||
do not line up with the values in the test database.
|
||||
2. One or more mandatory parameters were included in the "exclude" list.
|
||||
|
||||
To learn more about the test itself, see zerver/openapi/test_curl_examples.py.
|
||||
"""
|
||||
print(
|
||||
error_template.format(
|
||||
file_name=file_name,
|
||||
line=line,
|
||||
curl_command=generated_curl_command,
|
||||
response=(
|
||||
response_json
|
||||
if response is None
|
||||
else json.dumps(response, indent=4)
|
||||
),
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
assert_all_helper_functions_called()
|
||||
107
zerver/openapi/testing.yaml
Normal file
107
zerver/openapi/testing.yaml
Normal file
@@ -0,0 +1,107 @@
|
||||
openapi: 3.0.1
|
||||
info:
|
||||
title: Test API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: "http://localhost:9991/api/v1"
|
||||
paths:
|
||||
/test1:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
top_array:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
obj:
|
||||
oneOf:
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
- type: object
|
||||
properties:
|
||||
str3:
|
||||
type: string
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
str1:
|
||||
type: string
|
||||
str2:
|
||||
type: string
|
||||
/test2:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
top_array:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
obj:
|
||||
oneOf:
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
- type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
str3:
|
||||
type: string
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
str1:
|
||||
type: string
|
||||
str2:
|
||||
type: string
|
||||
/test3:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
top_array:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
obj:
|
||||
oneOf:
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
- type: object
|
||||
- type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
str1:
|
||||
type: string
|
||||
str2:
|
||||
type: string
|
||||
31902
zerver/openapi/zulip.yaml
Normal file
31902
zerver/openapi/zulip.yaml
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user