diff --git a/templates/zerver/api/add-playground.md b/templates/zerver/api/add-playground.md new file mode 100644 index 0000000000..31aae8b5f1 --- /dev/null +++ b/templates/zerver/api/add-playground.md @@ -0,0 +1,32 @@ +# Add a playground + +{generate_api_description(/realm/playgrounds:post)} + +## Usage examples + +{start_tabs} +{tab|python} + +{generate_code_example(python)|/realm/playgrounds:post|example} + +{tab|curl} + +{generate_code_example(curl)|/realm/playgrounds:post|example} + +{end_tabs} + +## Parameters + +{generate_api_arguments_table|zulip.yaml|/realm/playgrounds:post} + +## Response + +#### Return values + +{generate_return_values_table|zulip.yaml|/realm/playgrounds:post} + +#### Example response + +A typical successful JSON response may look like: + +{generate_code_example|/realm/playgrounds:post|fixture(200)} diff --git a/templates/zerver/help/include/rest-endpoints.md b/templates/zerver/help/include/rest-endpoints.md index c6c9df2f35..e358127cf9 100644 --- a/templates/zerver/help/include/rest-endpoints.md +++ b/templates/zerver/help/include/rest-endpoints.md @@ -59,6 +59,7 @@ * [Get linkifiers](/api/get-linkifiers) * [Add a linkifier](/api/add-linkifier) * [Remove a linkifier](/api/remove-linkifier) +* [Add a playground](/api/add-playground) * [Get all custom emoji](/api/get-custom-emoji) * [Upload custom emoji](/api/upload-custom-emoji) * [Get all custom profile fields](/api/get-custom-profile-fields) diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index db1aa84feb..91b1bed29b 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -197,6 +197,7 @@ from zerver.models import ( RealmDomain, RealmEmoji, RealmFilter, + RealmPlayground, Recipient, ScheduledEmail, ScheduledMessage, @@ -6591,6 +6592,16 @@ def do_remove_realm_domain( send_event(realm, event, active_user_ids(realm.id)) +def do_add_realm_playground(realm: Realm, **kwargs: Any) -> int: + realm_playground = RealmPlayground(realm=realm, **kwargs) + # We expect full_clean to always pass since a thorough input validation + # is performed in the view (using check_url, check_pygments_language, etc) + # before calling this function. + realm_playground.full_clean() + realm_playground.save() + return realm_playground.id + + def get_occupied_streams(realm: Realm) -> QuerySet: # TODO: Make a generic stub for QuerySet """ Get streams with subscribers """ diff --git a/zerver/openapi/curl_param_value_generators.py b/zerver/openapi/curl_param_value_generators.py index 1be861e71d..daa4dc34d1 100644 --- a/zerver/openapi/curl_param_value_generators.py +++ b/zerver/openapi/curl_param_value_generators.py @@ -5,7 +5,7 @@ # 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 json from functools import wraps from typing import Any, Callable, Dict, List, Optional, Set, Tuple @@ -268,6 +268,15 @@ def upload_custom_emoji() -> Dict[str, object]: } +@openapi_param_value_generator(["/realm/playgrounds:post"]) +def add_realm_playground() -> Dict[str, object]: + return { + "name": "Python2 playground", + "pygments_language": json.dumps("Python2"), + "url_prefix": json.dumps("https://python2.example.com"), + } + + @openapi_param_value_generator(["/users/{user_id}:delete"]) def deactivate_user() -> Dict[str, object]: user_profile = do_create_user( diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py index 126d363d57..43069c0b0a 100644 --- a/zerver/openapi/python_examples.py +++ b/zerver/openapi/python_examples.py @@ -380,6 +380,22 @@ def remove_realm_filter(client: Client) -> None: validate_against_openapi_schema(result, "/realm/filters/{filter_id}", "delete", "200") +@openapi_test_function("/realm/playgrounds:post") +def add_realm_playground(client: Client) -> None: + + # {code_example|start} + # Add a realm playground for Python + request = { + "name": "Python playground", + "pygments_language": json.dumps("Python"), + "url_prefix": json.dumps("https://python.example.com"), + } + result = client.call_endpoint(url="/realm/playgrounds", method="POST", request=request) + # {code_example|end} + + validate_against_openapi_schema(result, "/realm/playgrounds", "post", "200") + + @openapi_test_function("/users/me:get") def get_profile(client: Client) -> None: @@ -1417,6 +1433,7 @@ def test_server_organizations(client: Client) -> None: get_realm_filters(client) add_realm_filter(client) + add_realm_playground(client) get_server_settings(client) remove_realm_filter(client) get_realm_emoji(client) diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index bb68e9809f..1857ddcea5 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -6567,6 +6567,60 @@ paths: application/json: schema: $ref: "#/components/schemas/JsonSuccess" + /realm/playgrounds: + post: + operationId: add_realm_playground + tags: ["server_and_organizations"] + description: | + Configure realm playgrounds options to run code snippets occuring + in a code block using playgrounds which supports that language. + + `POST {{ api_url }}/v1/realm/playgrounds` + parameters: + - name: name + in: query + description: | + The user-visible display name of the playground which can be + used to pick the target playground, especially when multiple + playground options exist for that programming language. + schema: + type: string + example: "Python playground" + required: true + - name: pygments_language + in: query + description: | + The name of the Pygments language lexer for that + programming language. + schema: + type: string + example: "Python" + required: true + - name: url_prefix + in: query + description: | + The url prefix for the playground. + schema: + type: string + example: https://python.example.com + required: true + responses: + "200": + description: Success. + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/JsonSuccessBase" + - additionalProperties: false + properties: + result: {} + msg: {} + id: + type: integer + description: | + The numeric ID assigned to this playground. + example: {"id": 1, "result": "success", "msg": ""} /register: post: operationId: register_queue diff --git a/zerver/tests/test_realm_playgrounds.py b/zerver/tests/test_realm_playgrounds.py new file mode 100644 index 0000000000..7ec57a5e4c --- /dev/null +++ b/zerver/tests/test_realm_playgrounds.py @@ -0,0 +1,95 @@ +from typing import Dict + +import orjson + +from zerver.lib.test_classes import ZulipTestCase +from zerver.models import RealmPlayground, get_realm + + +class RealmPlaygroundTests(ZulipTestCase): + def json_serialize(self, payload: Dict[str, str]) -> Dict[str, str]: + payload["url_prefix"] = orjson.dumps(payload["url_prefix"]).decode() + payload["pygments_language"] = orjson.dumps(payload["pygments_language"]).decode() + return payload + + def test_create_one_playground_entry(self) -> None: + iago = self.example_user("iago") + + payload = { + "name": "Python playground", + "pygments_language": "Python", + "url_prefix": "https://python.example.com", + } + # Now send a POST request to the API endpoint. + resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload)) + self.assert_json_success(resp) + + # Check if the actual object exists + realm = get_realm("zulip") + self.assertTrue( + RealmPlayground.objects.filter(realm=realm, name="Python playground").exists() + ) + + def test_create_multiple_playgrounds_for_same_language(self) -> None: + iago = self.example_user("iago") + + data = [ + { + "name": "Python playground 1", + "pygments_language": "Python", + "url_prefix": "https://python.example.com", + }, + { + "name": "Python playground 2", + "pygments_language": "Python", + "url_prefix": "https://python2.example.com", + }, + ] + for payload in data: + resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload)) + self.assert_json_success(resp) + + realm = get_realm("zulip") + self.assertTrue( + RealmPlayground.objects.filter(realm=realm, name="Python playground 1").exists() + ) + self.assertTrue( + RealmPlayground.objects.filter(realm=realm, name="Python playground 2").exists() + ) + + def test_invalid_params(self) -> None: + iago = self.example_user("iago") + + payload = { + "name": "Invalid URL", + "pygments_language": "Python", + "url_prefix": "https://invalid-url", + } + resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload)) + self.assert_json_error(resp, "url_prefix is not a URL") + + payload["url_prefix"] = "https://python.example.com" + payload["pygments_language"] = "a$b$c" + resp = self.api_post(iago, "/json/realm/playgrounds", self.json_serialize(payload)) + self.assert_json_error(resp, "Invalid characters in pygments language") + + def test_create_already_existing_playground(self) -> None: + iago = self.example_user("iago") + + payload = { + "name": "Python playground", + "pygments_language": "Python", + "url_prefix": "https://python.example.com", + } + serialized_payload = self.json_serialize(payload) + resp = self.api_post(iago, "/json/realm/playgrounds", serialized_payload) + self.assert_json_success(resp) + + resp = self.api_post(iago, "/json/realm/playgrounds", serialized_payload) + self.assert_json_error(resp, "Realm playground with this Realm and Name already exists.") + + def test_not_realm_admin(self) -> None: + hamlet = self.example_user("hamlet") + + resp = self.api_post(hamlet, "/json/realm/playgrounds") + self.assert_json_error(resp, "Must be an organization administrator") diff --git a/zerver/views/realm_playgrounds.py b/zerver/views/realm_playgrounds.py new file mode 100644 index 0000000000..62cef4e0de --- /dev/null +++ b/zerver/views/realm_playgrounds.py @@ -0,0 +1,46 @@ +import re + +from django.core.exceptions import ValidationError +from django.http import HttpRequest, HttpResponse +from django.utils.translation import ugettext as _ + +from zerver.decorator import require_realm_admin +from zerver.lib.actions import do_add_realm_playground +from zerver.lib.request import REQ, JsonableError, has_request_variables +from zerver.lib.response import json_error, json_success +from zerver.lib.validator import check_capped_string, check_url +from zerver.models import RealmPlayground, UserProfile + + +def check_pygments_language(var_name: str, val: object) -> str: + s = check_capped_string(RealmPlayground.MAX_PYGMENTS_LANGUAGE_LENGTH)(var_name, val) + # We don't want to restrict the language here to be only from the list of valid + # Pygments languages. Keeping it open would allow us to hook up a "playground" + # for custom "languages" that aren't known to Pygments. We use a similar strategy + # even in our fenced_code markdown processor. + valid_pygments_language = re.compile(r"^[ a-zA-Z0-9_+-./#]*$") + matched_results = valid_pygments_language.match(s) + if not matched_results: + raise JsonableError(_("Invalid characters in pygments language")) + return s + + +@require_realm_admin +@has_request_variables +def add_realm_playground( + request: HttpRequest, + user_profile: UserProfile, + name: str = REQ(), + url_prefix: str = REQ(validator=check_url), + pygments_language: str = REQ(validator=check_pygments_language), +) -> HttpResponse: + try: + playground_id = do_add_realm_playground( + realm=user_profile.realm, + name=name.strip(), + pygments_language=pygments_language.strip(), + url_prefix=url_prefix.strip(), + ) + except ValidationError as e: + return json_error(e.messages[0], data={"errors": dict(e)}) + return json_success({"id": playground_id}) diff --git a/zproject/urls.py b/zproject/urls.py index 78a6006190..36d866e392 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -121,6 +121,7 @@ from zerver.views.realm_export import delete_realm_export, export_realm, get_rea from zerver.views.realm_icon import delete_icon_backend, get_icon_backend, upload_icon from zerver.views.realm_linkifiers import create_linkifier, delete_linkifier, list_linkifiers from zerver.views.realm_logo import delete_logo_backend, get_logo_backend, upload_logo +from zerver.views.realm_playgrounds import add_realm_playground from zerver.views.registration import ( accounts_home, accounts_home_from_multiuse_invite, @@ -268,6 +269,8 @@ v1_api_and_json_patterns = [ # realm/filters -> zerver.views.realm_linkifiers rest_path("realm/filters", GET=list_linkifiers, POST=create_linkifier), rest_path("realm/filters/", DELETE=delete_linkifier), + # realm/playgrounds -> zerver.views.realm_playgrounds + rest_path("realm/playgrounds", POST=add_realm_playground), # realm/profile_fields -> zerver.views.custom_profile_fields rest_path( "realm/profile_fields",