linkifiers: Update API to send data using dictionaries.

* This introduces a new event type `realm_linkifiers` and
a new key for the initial data fetch of the same name.
Newer clients will be expected to use these.

* Backwards compatibility is ensured by changing neither
the current event nor the /register key. The data which
these hold is the same as before, but internally, it is
generated by processing the `realm_linkifiers` data.
We send both the old and the new event types to clients
whenever the linkifiers are changed.
Older clients will simply ignore the new event type, and
vice versa.

* The `realm/filters:GET` endpoint (which returns tuples)
is currently used by none of the official Zulip clients.
This commit replaces it with `realm/linkifiers:GET` which
returns data in the new dictionary format.
TODO: Update the `get_realm_filters` method in the API
bindings, to hit this new URL instead of the old one.

* This also updates the webapp frontend to use the newer
events and keys.
This commit is contained in:
Abhijeet Prasad Bodas 2021-03-30 16:21:54 +05:30 committed by Tim Abbott
parent 5eff43f5d9
commit 3947b0c80a
21 changed files with 297 additions and 90 deletions

View File

@ -516,13 +516,13 @@ run_test("realm_emoji", (override) => {
}
});
run_test("linkifier", (override) => {
const event = event_fixtures.realm_filters;
page_params.realm_filters = [];
run_test("realm_linkifiers", (override) => {
const event = event_fixtures.realm_linkifiers;
page_params.realm_linkifiers = [];
override(settings_linkifiers, "populate_linkifiers", noop);
override(markdown, "update_linkifier_rules", noop);
dispatch(event);
assert_same(page_params.realm_filters, event.realm_filters);
assert_same(page_params.realm_linkifiers, event.realm_linkifiers);
});
run_test("realm_domains", (override) => {

View File

@ -456,9 +456,15 @@ exports.fixtures = {
],
},
realm_filters: {
type: "realm_filters",
realm_filters: [["#[123]", "ticket %(id)s", 55]],
realm_linkifiers: {
type: "realm_linkifiers",
realm_linkifiers: [
{
pattern: "#[123]",
url_format: "ticket %(id)s",
id: 55,
},
],
},
realm_user__add: {

View File

@ -13,13 +13,22 @@ set_global("location", {
origin: "http://zulip.zulipdev.com",
});
const example_realm_filters = [
["#(?P<id>[0-9]{2,8})", "https://trac.example.com/ticket/%(id)s"],
["ZBUG_(?P<id>[0-9]{2,8})", "https://trac2.zulip.net/ticket/%(id)s"],
[
"ZGROUP_(?P<id>[0-9]{2,8}):(?P<zone>[0-9]{1,8})",
"https://zone_%(zone)s.zulip.net/ticket/%(id)s",
],
const example_realm_linkifiers = [
{
pattern: "#(?P<id>[0-9]{2,8})",
url_format: "https://trac.example.com/ticket/%(id)s",
id: 1,
},
{
pattern: "ZBUG_(?P<id>[0-9]{2,8})",
url_format: "https://trac2.zulip.net/ticket/%(id)s",
id: 2,
},
{
pattern: "ZGROUP_(?P<id>[0-9]{2,8}):(?P<zone>[0-9]{1,8})",
url_format: "https://zone_%(zone)s.zulip.net/ticket/%(id)s",
id: 3,
},
];
page_params.translate_emoticons = false;
@ -179,12 +188,12 @@ stream_data.add_sub(edgecase_stream_2);
// streamTopicHandler and it would be parsed as edgecase_stream_2.
stream_data.add_sub(amp_stream);
markdown.initialize(example_realm_filters, markdown_config.get_helpers());
markdown.initialize(example_realm_linkifiers, markdown_config.get_helpers());
function test(label, f) {
run_test(label, (override) => {
page_params.realm_users = [];
markdown.update_linkifier_rules(example_realm_filters);
markdown.update_linkifier_rules(example_realm_linkifiers);
f(override);
});
}
@ -713,13 +722,28 @@ test("backend_only_linkifiers", () => {
test("python_to_js_linkifier", () => {
// The only way to reach python_to_js_linkifier is indirectly, hence the call
// to update_linkifier_rules.
markdown.update_linkifier_rules([["/a(?im)a/g"], ["/a(?L)a/g"]]);
markdown.update_linkifier_rules([
{
pattern: "/a(?im)a/g",
url_format: "http://example1.example.com",
id: 10,
},
{
pattern: "/a(?L)a/g",
url_format: "http://example2.example.com",
id: 20,
},
]);
let actual_value = marked.InlineLexer.rules.zulip.linkifiers;
let expected_value = [/\/aa\/g(?!\w)/gim, /\/aa\/g(?!\w)/g];
assert.deepEqual(actual_value, expected_value);
// Test case with multiple replacements.
markdown.update_linkifier_rules([
["#cf(?P<contest>\\d+)(?P<problem>[A-Z][\\dA-Z]*)", "http://google.com"],
{
pattern: "#cf(?P<contest>\\d+)(?P<problem>[A-Z][\\dA-Z]*)",
url_format: "http://example3.example.com",
id: 30,
},
]);
actual_value = marked.InlineLexer.rules.zulip.linkifiers;
expected_value = [/#cf(\d+)([A-Z][\dA-Z]*)(?!\w)/g];
@ -729,7 +753,13 @@ test("python_to_js_linkifier", () => {
"error",
"python_to_js_linkifier: Invalid regular expression: /!@#@(!#&((!&(@#((?!\\w)/: Unterminated group",
);
markdown.update_linkifier_rules([["!@#@(!#&((!&(@#(", "http://google.com"]]);
markdown.update_linkifier_rules([
{
pattern: "!@#@(!#&((!&(@#(",
url_format: "http://example4.example.com",
id: 40,
},
]);
actual_value = marked.InlineLexer.rules.zulip.linkifiers;
expected_value = [];
assert.deepEqual(actual_value, expected_value);

View File

@ -89,7 +89,7 @@ export function contains_backend_only_syntax(content) {
// then don't render it locally. It is workaround for the fact that
// javascript regex doesn't support lookbehind.
const false_linkifier_match = linkifier_list.find((re) => {
const pattern = /[^\s"'(,:<]/.source + re[0].source + /(?!\w)/.source;
const pattern = /[^\s"'(,:<]/.source + re.pattern.source + /(?!\w)/.source;
const regex = new RegExp(pattern);
return regex.test(content);
});
@ -225,8 +225,8 @@ export function add_topic_links(message) {
const links = [];
for (const linkifier of linkifier_list) {
const pattern = linkifier[0];
const url = linkifier[1];
const pattern = linkifier.pattern;
const url = linkifier.url_format;
let match;
while ((match = pattern.exec(topic)) !== null) {
let link_url = url;
@ -451,15 +451,18 @@ export function update_linkifier_rules(linkifiers) {
const marked_rules = [];
for (const [pattern, url] of linkifiers) {
const [regex, final_url] = python_to_js_linkifier(pattern, url);
for (const linkifier of linkifiers) {
const [regex, final_url] = python_to_js_linkifier(linkifier.pattern, linkifier.url_format);
if (!regex) {
// Skip any linkifiers that could not be converted
continue;
}
linkifier_map.set(regex, final_url);
linkifier_list.push([regex, final_url]);
linkifier_list.push({
pattern: regex,
url_format: final_url,
});
marked_rules.push(regex);
}

View File

@ -342,10 +342,10 @@ export function dispatch_normal_event(event) {
composebox_typeahead.update_emoji_data();
break;
case "realm_filters":
page_params.realm_filters = event.realm_filters;
markdown.update_linkifier_rules(page_params.realm_filters);
settings_linkifiers.populate_linkifiers(page_params.realm_filters);
case "realm_linkifiers":
page_params.realm_linkifiers = event.realm_linkifiers;
markdown.update_linkifier_rules(page_params.realm_linkifiers);
settings_linkifiers.populate_linkifiers(page_params.realm_linkifiers);
break;
case "realm_domains":

View File

@ -23,21 +23,21 @@ export function maybe_disable_widgets() {
}
}
function compare_by_index(a, b, i) {
if (a[i] > b[i]) {
function compare_values(x, y) {
if (x > y) {
return 1;
} else if (a[i] === b[i]) {
} else if (x === y) {
return 0;
}
return -1;
}
function sort_pattern(a, b) {
return compare_by_index(a, b, 0);
return compare_values(a.pattern, b.pattern);
}
function sort_url(a, b) {
return compare_by_index(a, b, 1);
return compare_values(a.url_format, b.url_format);
}
export function populate_linkifiers(linkifiers_data) {
@ -51,9 +51,9 @@ export function populate_linkifiers(linkifiers_data) {
modifier(linkifier) {
return render_admin_linkifier_list({
linkifier: {
pattern: linkifier[0],
url_format_string: linkifier[1],
id: linkifier[2],
pattern: linkifier.pattern,
url_format_string: linkifier.url_format,
id: linkifier.id,
},
can_modify: page_params.is_admin,
});
@ -62,7 +62,8 @@ export function populate_linkifiers(linkifiers_data) {
element: linkifiers_table.closest(".settings-section").find(".search"),
predicate(item, value) {
return (
item[0].toLowerCase().includes(value) || item[1].toLowerCase().includes(value)
item.pattern.toLowerCase().includes(value) ||
item.url_format.toLowerCase().includes(value)
);
},
onupdate() {
@ -88,7 +89,7 @@ export function build_page() {
meta.loaded = true;
// Populate linkifiers table
populate_linkifiers(page_params.realm_filters);
populate_linkifiers(page_params.realm_linkifiers);
$(".admin_linkifiers_table").on("click", ".delete", function (e) {
e.preventDefault();

View File

@ -496,7 +496,7 @@ export function initialize_everything() {
realm_emoji: emoji_params.realm_emoji,
emoji_codes: generated_emoji_codes,
});
markdown.initialize(page_params.realm_filters, markdown_config.get_helpers());
markdown.initialize(page_params.realm_linkifiers, markdown_config.get_helpers());
compose.initialize();
composebox_typeahead.initialize(); // Must happen after compose.initialize()
search.initialize();

View File

@ -10,6 +10,23 @@ below features are supported.
## Changes in Zulip 4.0
**Feature level 54**
* `GET /realm/filters` has been removed and replace with [`GET
/realm/linkifiers`](/api/get-linkifiers) which returns the data in a
cleaner dictionary format.
* [`GET /events`](/api/get-events): Introduced new event type
`realm_linkifiers`. The previous `realm_filters` event type is
still supported for backwards compatibility, but will be removed in
a future release.
* [`POST /register`](/api/register-queue): The response now supports a
`realm_linkifiers` event type, containing the same data as the
legacy `realm_filters` key, with a more extensible object
format. The previous `realm_filters` event type is still supported
for backwards compatibility, but will be removed in a future
release. The legacy `realm_filters` key is deprecated but remains
available for backwards compatibility.
**Feature level 53**
* [`POST /register`](/api/register-queue): Added `max_topic_length`

View File

@ -1,32 +1,32 @@
# Get linkifiers
{generate_api_description(/realm/filters:get)}
{generate_api_description(/realm/linkifiers:get)}
## Usage examples
{start_tabs}
{tab|python}
{generate_code_example(python)|/realm/filters:get|example}
{generate_code_example(python)|/realm/linkifiers:get|example}
{tab|curl}
{generate_code_example(curl)|/realm/filters:get|example}
{generate_code_example(curl)|/realm/linkifiers:get|example}
{end_tabs}
## Parameters
{generate_api_arguments_table|zulip.yaml|/realm/filters:get}
{generate_api_arguments_table|zulip.yaml|/realm/linkifiers:get}
## Response
#### Return values
{generate_return_values_table|zulip.yaml|/realm/filters:get}
{generate_return_values_table|zulip.yaml|/realm/linkifiers:get}
#### Example response
A typical successful JSON response may look like:
{generate_code_example|/realm/filters:get|fixture(200)}
{generate_code_example|/realm/linkifiers:get|fixture(200)}

View File

@ -30,7 +30,7 @@ DESKTOP_WARNING_VERSION = "5.2.0"
#
# Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md.
API_FEATURE_LEVEL = 53
API_FEATURE_LEVEL = 54
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@ -236,6 +236,7 @@ from zerver.models import (
get_user_by_id_in_realm_including_cross_realm,
get_user_profile_by_id,
is_cross_realm_bot_email,
linkifiers_for_realm,
query_for_ids,
realm_filters_for_realm,
validate_attachment_request,
@ -6593,6 +6594,13 @@ def do_mark_hotspot_as_read(user: UserProfile, hotspot: str) -> None:
def notify_linkifiers(realm: Realm) -> None:
realm_linkifiers = linkifiers_for_realm(realm.id)
event = dict(type="realm_linkifiers", realm_linkifiers=realm_linkifiers)
send_event(realm, event, active_user_ids(realm.id))
# Below is code for backwards compatibility. The now deprecated
# "realm_filters" event-type is used by older clients, and uses
# tuples.
realm_filters = realm_filters_for_realm(realm.id)
event = dict(type="realm_filters", realm_filters=realm_filters)
send_event(realm, event, active_user_ids(realm.id))

View File

@ -781,9 +781,26 @@ def check_realm_export(
assert has_failed_timestamp == (export["failed_timestamp"] is not None)
# This type, like other instances of TupleType, is a legacy feature of
# a very old Zulip API; we plan to replace it with an object as those
# are more extensible.
realm_linkifier_type = DictType(
required_keys=[
("pattern", str),
("url_format", str),
("id", int),
]
)
realm_linkifiers_event = event_dict_type(
[
("type", Equals("realm_linkifiers")),
("realm_linkifiers", ListType(realm_linkifier_type)),
]
)
check_realm_linkifiers = make_checker(realm_linkifiers_event)
# This is a legacy event type to ensure backwards compatibility
# for old clients. Newer clients should handle only the
# "realm_linkifiers" event above.
realm_filter_type = TupleType(
[
# we should make this an object

View File

@ -62,6 +62,7 @@ from zerver.models import (
get_default_stream_groups,
get_realm_domains,
get_realm_playgrounds,
linkifiers_for_realm,
realm_filters_for_realm,
)
from zerver.tornado.django_api import get_user_events, request_event_queue
@ -263,6 +264,10 @@ def fetch_initial_state_data(
if want("realm_emoji"):
state["realm_emoji"] = realm.get_emoji()
if want("realm_linkifiers"):
state["realm_linkifiers"] = linkifiers_for_realm(realm.id)
# Backwards compatibility code.
if want("realm_filters"):
state["realm_filters"] = realm_filters_for_realm(realm.id)
@ -993,6 +998,8 @@ def apply_event(
state["muted_users"] = event["muted_users"]
elif event["type"] == "realm_filters":
state["realm_filters"] = event["realm_filters"]
elif event["type"] == "realm_linkifiers":
state["realm_linkifiers"] = event["realm_linkifiers"]
elif event["type"] == "realm_playgrounds":
state["realm_playgrounds"] = event["realm_playgrounds"]
elif event["type"] == "update_display_settings":

View File

@ -308,15 +308,18 @@ def get_subscription_status(client: Client) -> None:
)
@openapi_test_function("/realm/filters:get")
def get_realm_filters(client: Client) -> None:
@openapi_test_function("/realm/linkifiers:get")
def get_realm_linkifiers(client: Client) -> None:
# {code_example|start}
# Fetch all the filters in this organization
result = client.get_realm_filters()
result = client.call_endpoint(
url="/realm/linkifiers",
method="GET",
)
# {code_example|end}
validate_against_openapi_schema(result, "/realm/filters", "get", "200")
validate_against_openapi_schema(result, "/realm/linkifiers", "get", "200")
@openapi_test_function("/realm/profile_fields:get")
@ -1459,7 +1462,7 @@ def test_queues(client: Client) -> None:
def test_server_organizations(client: Client) -> None:
get_realm_filters(client)
get_realm_linkifiers(client)
add_realm_filter(client)
add_realm_playground(client)
get_server_settings(client)

View File

@ -2377,6 +2377,69 @@ paths:
Processing this event is important to doing Markdown local echo
correctly.
**Changes**: New in Zulip 4.0 (feature level 54), replacing the
previous `realm_filters` event type, which is still sent for
backwards compatibility reasons.
Clients should migrate to requesting and processing the
`realm_linkifiers` event type when possible, since we plan to remove
the legacy `realm_filters` logic entirely in a future release.
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
type:
allOf:
- $ref: "#/components/schemas/EventTypeSchema"
- enum:
- realm_linkifiers
realm_linkifiers:
type: array
description: |
Array of dictionaries where each dictionary contains details about
a single realm linkifier.
items:
type: object
additionalProperties: false
properties:
pattern:
type: string
description: |
The string regex pattern which represents the pattern that
should be linkified by this linkifier.
url_format:
type: string
description: |
The URL format string to be used for linkifying matches.
id:
type: integer
description: |
The ID of the linkifier.
example:
{
"type": "realm_linkifiers",
"realm_linkifiers":
[
{
"pattern": "#(?P<id>[123])",
"url_format": "https://realm.com/my_realm_filter/%(id)s",
"id": 1,
},
],
"id": 0,
}
- type: object
additionalProperties: false
deprecated: true
description: |
Legacy event type. Sent to all users in a Zulip organization
when the set of configured [linkifiers](/help/add-a-custom-linkifier)
for the organization has changed.
**Changes**: Deprecated in Zulip 4.0 (feature level 54), replaced by
the `realm_linkifiers` event type, which has a clearer name and format,
instead.
properties:
id:
$ref: "#/components/schemas/EventIdSchema"
@ -6485,7 +6548,7 @@ paths:
"msg": "Cannot deactivate the only organization owner",
"result": "error",
}
/realm/filters:
/realm/linkifiers:
get:
operationId: get_linkifiers
tags: ["server_and_organizations"]
@ -6495,7 +6558,11 @@ paths:
expression patterns that are automatically linkified when they appear
in messages and topics.
`GET {{ api_url }}/v1/realm/filters`
`GET {{ api_url }}/v1/realm/linkifiers`
**Changes**: New in Zulip 4.0 (feature level 54). On older versions,
a similar `GET /realm/filters` endpoint was available with each entry in
a `[pattern, url_format, id]` tuple format.
responses:
"200":
description: Success.
@ -6508,35 +6575,41 @@ paths:
properties:
result: {}
msg: {}
filters:
linkifiers:
type: array
items:
type: array
items:
oneOf:
- type: string
- type: integer
description: |
An array of tuples, each representing one of
the linkifiers set up in the
organization. Each of these tuples contain the
pattern, the formatted URL and the filter's
ID, in that order. See the [Create
linkifiers](/api/add-linkifier) article for
details on what each field means.
An array of objects, where each object describes a linkifier.
items:
type: object
additionalProperties: false
properties:
pattern:
type: string
description: |
The string regex pattern which represents the pattern that
should be linkified by this linkifier.
url_format:
type: string
description: |
The URL format string to be used for linkifying matches.
id:
type: integer
description: |
The ID of the linkifier.
example:
{
"msg": "",
"filters":
"linkifiers":
[
[
"#(?P<id>[0-9]+)",
"https://github.com/zulip/zulip/issues/%(id)s",
1,
],
{
"pattern": "#(?P<id>[0-9]+)",
"url_format": "https://github.com/zulip/zulip/issues/%(id)s",
"id": 1,
},
],
"result": "success",
}
/realm/filters:
post:
operationId: add_linkifier
tags: ["server_and_organizations"]
@ -7009,8 +7082,37 @@ paths:
- type: array
items:
type: integer
realm_linkifiers:
type: array
description: |
Present if `realm_linkifiers` is present in `fetch_event_types`.
Array of objects where each object describes a single
[linkifier](/help/add-a-custom-linkifier).
**Changes**: New in Zulip 4.0 (feature level 54). Clients can
access these data on older server versions via the previous
`realm_filters` key.
items:
type: object
additionalProperties: false
properties:
pattern:
type: string
description: |
The string regex pattern which represents the pattern that
should be linkified on matching.
url_format:
type: string
description: |
The URL with which the pattern matching string should be linkified.
id:
type: integer
description: |
The ID of the linkifier.
realm_filters:
type: array
deprecated: true
items:
type: array
items:
@ -7018,16 +7120,20 @@ paths:
- type: integer
- type: string
description: |
Present if `realm_filters` is present in `fetch_event_types`.
Legacy property for linkifiers. Present if `realm_filters` is
present in `fetch_event_types`.
An array of tuples (fixed-length arrays) where each tuple describes
a single realm filter ([linkifier](/help/add-a-custom-linkifier).
a single [linkifier](/help/add-a-custom-linkifier).
The first element of the tuple is a string regex pattern which represents
the pattern that should be linkified on matching.
The second element is the URL with which the
pattern matching string should be linkified with and the third element
is the id of the realm filter.
**Changes**: Deprecated in Zulip 4.0 (feature level 54), replaced by
the `realm_linkifiers` key instead.
realm_playgrounds:
type: array
items:

View File

@ -871,6 +871,7 @@ class FetchQueriesTest(ZulipTestCase):
realm_incoming_webhook_bots=0,
realm_emoji=1,
realm_filters=1,
realm_linkifiers=1,
realm_playgrounds=1,
realm_user=3,
realm_user_groups=2,

View File

@ -128,6 +128,7 @@ from zerver.lib.event_schema import (
check_realm_emoji_update,
check_realm_export,
check_realm_filters,
check_realm_linkifiers,
check_realm_playgrounds,
check_realm_update,
check_realm_update_dict,
@ -1349,13 +1350,18 @@ class NormalActionsTest(BaseAction):
regex = "#(?P<id>[123])"
url = "https://realm.com/my_realm_filter/%(id)s"
events = self.verify_action(lambda: do_add_linkifier(self.user_profile.realm, regex, url))
check_realm_filters("events[0]", events[0])
events = self.verify_action(
lambda: do_add_linkifier(self.user_profile.realm, regex, url), num_events=2
)
check_realm_linkifiers("events[0]", events[0])
check_realm_filters("events[1]", events[1])
events = self.verify_action(
lambda: do_remove_linkifier(self.user_profile.realm, "#(?P<id>[123])")
lambda: do_remove_linkifier(self.user_profile.realm, "#(?P<id>[123])"),
num_events=2,
)
check_realm_filters("events[0]", events[0])
check_realm_linkifiers("events[0]", events[0])
check_realm_filters("events[1]", events[1])
def test_realm_domain_events(self) -> None:
events = self.verify_action(

View File

@ -167,6 +167,7 @@ class HomeTest(ZulipTestCase):
"realm_invite_to_realm_policy",
"realm_invite_to_stream_policy",
"realm_is_zephyr_mirror_realm",
"realm_linkifiers",
"realm_logo_source",
"realm_logo_url",
"realm_mandatory_topics",

View File

@ -10,10 +10,10 @@ class RealmFilterTest(ZulipTestCase):
self.login("iago")
realm = get_realm("zulip")
do_add_linkifier(realm, "#(?P<id>[123])", "https://realm.com/my_realm_filter/%(id)s")
result = self.client_get("/json/realm/filters")
result = self.client_get("/json/realm/linkifiers")
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
self.assertEqual(len(result.json()["filters"]), 1)
self.assertEqual(len(result.json()["linkifiers"]), 1)
def test_create(self) -> None:
self.login("iago")

View File

@ -6,13 +6,13 @@ from zerver.decorator import require_realm_admin
from zerver.lib.actions import do_add_linkifier, do_remove_linkifier
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_error, json_success
from zerver.models import RealmFilter, UserProfile, realm_filters_for_realm
from zerver.models import RealmFilter, UserProfile, linkifiers_for_realm
# Custom realm linkifiers
def list_linkifiers(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
filters = realm_filters_for_realm(user_profile.realm_id)
return json_success({"filters": filters})
linkifiers = linkifiers_for_realm(user_profile.realm_id)
return json_success({"linkifiers": linkifiers})
@require_realm_admin

View File

@ -262,8 +262,9 @@ v1_api_and_json_patterns = [
rest_path("realm/icon", POST=upload_icon, DELETE=delete_icon_backend, GET=get_icon_backend),
# realm/logo -> zerver.views.realm_logo
rest_path("realm/logo", POST=upload_logo, DELETE=delete_logo_backend, GET=get_logo_backend),
# realm/filters -> zerver.views.realm_linkifiers
rest_path("realm/filters", GET=list_linkifiers, POST=create_linkifier),
# realm/filters and realm/linkifiers -> zerver.views.realm_linkifiers
rest_path("realm/linkifiers", GET=list_linkifiers),
rest_path("realm/filters", POST=create_linkifier),
rest_path("realm/filters/<int:filter_id>", DELETE=delete_linkifier),
# realm/playgrounds -> zerver.views.realm_playgrounds
rest_path("realm/playgrounds", POST=add_realm_playground),