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

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

View File

View 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}

View 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();

View 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.")

View 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)

View 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
View 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)

File diff suppressed because it is too large Load Diff

View 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
View 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

File diff suppressed because it is too large Load Diff