Repository: zulip/zulip

Issue:
Add endpoint to get a stream by ID
Apparently, we've never added an endpoint to get the details of a single stream by ID -- probably because most clients tend to want all the streams in a given category.

This should be `GET /streams/{stream_id}` and implemented using a combination of `Stream.get_client_data` and `access_stream_by_id`.


Discussion:
Hello @zulip/server-api, @zulip/server-streams members, this issue was labeled with the "area: stream settings", "area: api" labels, so you may want to check it out!

<!-- areaLabelAddition -->

@zulipbot claim

Resolving patch:
diff --git a/version.py b/version.py
--- a/version.py
+++ b/version.py
@@ -33,7 +33,7 @@
 # Changes should be accompanied by documentation explaining what the
 # new level means in templates/zerver/api/changelog.md, as well as
 # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`.
-API_FEATURE_LEVEL = 130
+API_FEATURE_LEVEL = 132
 
 # 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
diff --git a/zerver/views/streams.py b/zerver/views/streams.py
--- a/zerver/views/streams.py
+++ b/zerver/views/streams.py
@@ -754,6 +754,16 @@ def get_streams_backend(
     return json_success(request, data={"streams": streams})
 
 
+@has_request_variables
+def get_stream_backend(
+    request: HttpRequest,
+    user_profile: UserProfile,
+    stream_id: int,
+) -> HttpResponse:
+    (stream, sub) = access_stream_by_id(user_profile, stream_id, allow_realm_admin=True)
+    return json_success(request, data={"stream": stream.to_dict()})
+
+
 @has_request_variables
 def get_topics_backend(
     request: HttpRequest,
diff --git a/zproject/urls.py b/zproject/urls.py
--- a/zproject/urls.py
+++ b/zproject/urls.py
@@ -145,6 +145,7 @@
     create_default_stream_group,
     deactivate_stream_backend,
     delete_in_topic,
+    get_stream_backend,
     get_streams_backend,
     get_subscribers_backend,
     get_topics_backend,
@@ -443,7 +444,10 @@
     # GET returns "stream info" (undefined currently?), HEAD returns whether stream exists (200 or 404)
     rest_path("streams/<int:stream_id>/members", GET=get_subscribers_backend),
     rest_path(
-        "streams/<int:stream_id>", PATCH=update_stream_backend, DELETE=deactivate_stream_backend
+        "streams/<int:stream_id>",
+        GET=get_stream_backend,
+        PATCH=update_stream_backend,
+        DELETE=deactivate_stream_backend,
     ),
     # Delete topic in stream
     rest_path("streams/<int:stream_id>/delete_topic", POST=delete_in_topic),


Test patch:
diff --git a/zerver/tests/test_decorators.py b/zerver/tests/test_decorators.py
--- a/zerver/tests/test_decorators.py
+++ b/zerver/tests/test_decorators.py
@@ -1983,7 +1983,7 @@ def test_options_method(self) -> None:
 
         result = self.client_options("/json/streams/15")
         self.assertEqual(result.status_code, 204)
-        self.assertEqual(str(result["Allow"]), "DELETE, PATCH")
+        self.assertEqual(str(result["Allow"]), "DELETE, GET, HEAD, PATCH")
 
     def test_http_accept_redirect(self) -> None:
         result = self.client_get("/json/users", HTTP_ACCEPT="text/html")
diff --git a/zerver/tests/test_subs.py b/zerver/tests/test_subs.py
--- a/zerver/tests/test_subs.py
+++ b/zerver/tests/test_subs.py
@@ -5457,6 +5457,40 @@ def test_public_streams_api(self) -> None:
         ]
         self.assertEqual(sorted(s["name"] for s in json["streams"]), sorted(all_streams))
 
+    def test_get_single_stream_api(self) -> None:
+        self.login("hamlet")
+        realm = get_realm("zulip")
+        denmark_stream = get_stream("Denmark", realm)
+        result = self.client_get(f"/json/streams/{denmark_stream.id}")
+        self.assert_json_success(result)
+        json = result.json()
+        self.assertEqual(json["stream"]["name"], "Denmark")
+        self.assertEqual(json["stream"]["stream_id"], denmark_stream.id)
+
+        result = self.client_get("/json/streams/9999")
+        self.assert_json_error(result, "Invalid stream id")
+
+        private_stream = self.make_stream("private_stream", invite_only=True)
+        self.subscribe(self.example_user("cordelia"), "private_stream")
+
+        # Non-admins cannot access unsubscribed private streams.
+        result = self.client_get(f"/json/streams/{private_stream.id}")
+        self.assert_json_error(result, "Invalid stream id")
+
+        self.login("iago")
+        result = self.client_get(f"/json/streams/{private_stream.id}")
+        self.assert_json_success(result)
+        json = result.json()
+        self.assertEqual(json["stream"]["name"], "private_stream")
+        self.assertEqual(json["stream"]["stream_id"], private_stream.id)
+
+        self.login("cordelia")
+        result = self.client_get(f"/json/streams/{private_stream.id}")
+        self.assert_json_success(result)
+        json = result.json()
+        self.assertEqual(json["stream"]["name"], "private_stream")
+        self.assertEqual(json["stream"]["stream_id"], private_stream.id)
+
 
 class StreamIdTest(ZulipTestCase):
     def test_get_stream_id(self) -> None: