streams: Convert to typed_enpoint.

This commit is contained in:
Kenneth Rodrigues 2024-07-20 14:40:02 +05:30 committed by Tim Abbott
parent 025fba0a11
commit 8b489f4b96
4 changed files with 211 additions and 183 deletions

View File

@ -376,7 +376,12 @@ def parse_value_for_parameter(parameter: FuncParam[T], value: object) -> T:
elif error["type"] == "value_error":
context["msg"] = error["msg"]
error_template = _("Invalid {var_name}: {msg}")
elif error["type"] == "model_type":
context["msg"] = error["msg"]
error_template = _("Invalid {var_name}: {msg}")
elif error["type"] == "missing":
context["msg"] = error["msg"]
error_template = _("{var_name} field is missing: {msg}")
assert error_template is not None, MISSING_ERROR_TEMPLATE.format(
error_type=error["type"],
url=error.get("url", "(documentation unavailable)"),

View File

@ -1,3 +1,4 @@
import re
import zoneinfo
from collections.abc import Collection
@ -83,3 +84,12 @@ def to_non_negative_int_or_none(s: str) -> NonNegativeInt | None:
# integer, and we want to return None in that case.
def non_negative_int_or_none_validator() -> BeforeValidator:
return BeforeValidator(lambda s: to_non_negative_int_or_none(s))
def check_color(var_name: str, val: object) -> str:
s = str(val)
valid_color_pattern = re.compile(r"^#([a-fA-F0-9]{3,6})$")
matched_results = valid_color_pattern.match(s)
if not matched_results:
raise ValueError(_("{var_name} is not a valid hex color code").format(var_name=var_name))
return s

View File

@ -3403,7 +3403,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode()
},
)
self.assert_json_error(result, "color is not a valid hex color code")
self.assert_json_error(
result, "Invalid subscription_data[0]: Value error, color is not a valid hex color code"
)
def test_set_color_missing_stream_id(self) -> None:
"""
@ -3420,7 +3422,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode()
},
)
self.assert_json_error(result, "stream_id key is missing from subscription_data[0]")
self.assert_json_error(
result, 'subscription_data[0]["stream_id"] field is missing: Field required'
)
def test_set_color_unsubscribed_stream_id(self) -> None:
"""
@ -3468,7 +3472,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode()
},
)
self.assert_json_error(result, "value key is missing from subscription_data[0]")
self.assert_json_error(
result, 'subscription_data[0]["value"] field is missing: Field required'
)
def test_set_stream_wildcard_mentions_notify(self) -> None:
"""
@ -3733,7 +3739,9 @@ class SubscriptionPropertiesTest(ZulipTestCase):
).decode()
},
)
self.assert_json_error(result, f"{property_name} is not a string")
self.assert_json_error(
result, "Invalid subscription_data[0]: Value error, color is not a valid hex color code"
)
def test_json_subscription_property_invalid_stream(self) -> None:
test_user = self.example_user("hamlet")
@ -3838,7 +3846,9 @@ class SubscriptionRestApiTest(ZulipTestCase):
# incorrect color format
subscriptions = [{"name": "my_test_stream_3", "color": "#0g0g0g"}]
result = self.common_subscribe_to_streams(user, subscriptions, allow_fail=True)
self.assert_json_error(result, 'subscriptions[0]["color"] is not a valid hex color code')
self.assert_json_error(
result, "Invalid subscriptions[0]: Value error, add.color is not a valid hex color code"
)
def test_api_valid_property(self) -> None:
"""
@ -3881,7 +3891,7 @@ class SubscriptionRestApiTest(ZulipTestCase):
result = self.api_patch(
user,
"/api/v1/users/me/subscriptions/121",
{"property": "is_muted", "value": "somevalue"},
{"property": "is_muted", "value": orjson.dumps(True).decode()},
)
self.assert_json_error(result, "Invalid channel ID")
@ -3896,8 +3906,11 @@ class SubscriptionRestApiTest(ZulipTestCase):
result = self.api_patch(user, "/api/v1/users/me/subscriptions", request)
self.assert_json_error(result, expected_message)
check_for_error(["foo"], "add[0] is not a dict")
check_for_error([{"bogus": "foo"}], "name key is missing from add[0]")
check_for_error(
["foo"],
"Invalid add[0]: Input should be a valid dictionary or instance of AddSubscriptionData",
)
check_for_error([{"bogus": "foo"}], 'add[0]["name"] field is missing: Field required')
check_for_error([{"name": {}}], 'add[0]["name"] is not a string')
def test_bad_principals(self) -> None:
@ -3909,7 +3922,7 @@ class SubscriptionRestApiTest(ZulipTestCase):
"principals": orjson.dumps([{}]).decode(),
}
result = self.api_patch(user, "/api/v1/users/me/subscriptions", request)
self.assert_json_error(result, "principals is not an allowed_type")
self.assert_json_error(result, 'principals["list[str]"][0] is not a string')
def test_bad_delete_parameters(self) -> None:
user = self.example_user("hamlet")

View File

@ -1,16 +1,16 @@
import time
from collections import defaultdict
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from collections.abc import Callable
from typing import Annotated, Any
import orjson
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from django.utils.translation import override as override_language
from pydantic import BaseModel, Field, Json, NonNegativeInt, StringConstraints, model_validator
from zerver.actions.default_streams import (
do_add_default_stream,
@ -48,7 +48,6 @@ from zerver.lib.email_mirror_helpers import encode_email_address
from zerver.lib.exceptions import JsonableError, OrganizationOwnerRequiredError
from zerver.lib.mention import MentionBackend, silent_mention_syntax_for_user
from zerver.lib.message import bulk_access_stream_messages_query
from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success
from zerver.lib.retention import STREAM_MESSAGE_BATCH_SIZE as RETENTION_STREAM_MESSAGE_BATCH_SIZE
from zerver.lib.retention import parse_message_retention_days
@ -73,24 +72,11 @@ from zerver.lib.topic import (
get_topic_history_for_stream,
messages_for_topic,
)
from zerver.lib.types import Validator
from zerver.lib.typed_endpoint import ApiParamConfig, PathOnly, typed_endpoint
from zerver.lib.typed_endpoint_validators import check_color, check_int_in_validator
from zerver.lib.user_groups import access_user_group_for_setting
from zerver.lib.users import access_user_by_email, access_user_by_id
from zerver.lib.utils import assert_is_not_none
from zerver.lib.validator import (
check_bool,
check_capped_string,
check_color,
check_dict,
check_dict_only,
check_int,
check_int_in,
check_list,
check_string,
check_string_or_int,
check_union,
to_non_negative_int,
)
from zerver.models import NamedUserGroup, Realm, Stream, UserProfile
from zerver.models.users import get_system_bot
@ -125,9 +111,9 @@ def deactivate_stream_backend(
@require_realm_admin
@has_request_variables
@typed_endpoint
def add_default_stream(
request: HttpRequest, user_profile: UserProfile, stream_id: int = REQ(json_validator=check_int)
request: HttpRequest, user_profile: UserProfile, *, stream_id: Json[int]
) -> HttpResponse:
(stream, sub) = access_stream_by_id(user_profile, stream_id)
if stream.invite_only:
@ -137,13 +123,14 @@ def add_default_stream(
@require_realm_admin
@has_request_variables
@typed_endpoint
def create_default_stream_group(
request: HttpRequest,
user_profile: UserProfile,
group_name: str = REQ(),
description: str = REQ(),
stream_names: list[str] = REQ(json_validator=check_list(check_string)),
*,
group_name: str,
description: str,
stream_names: Json[list[str]],
) -> HttpResponse:
streams = []
for stream_name in stream_names:
@ -154,13 +141,14 @@ def create_default_stream_group(
@require_realm_admin
@has_request_variables
@typed_endpoint
def update_default_stream_group_info(
request: HttpRequest,
user_profile: UserProfile,
group_id: int,
new_group_name: str | None = REQ(default=None),
new_description: str | None = REQ(default=None),
*,
group_id: PathOnly[int],
new_group_name: str | None = None,
new_description: str | None = None,
) -> HttpResponse:
if not new_group_name and not new_description:
raise JsonableError(_('You must pass "new_description" or "new_group_name".'))
@ -174,13 +162,14 @@ def update_default_stream_group_info(
@require_realm_admin
@has_request_variables
@typed_endpoint
def update_default_stream_group_streams(
request: HttpRequest,
user_profile: UserProfile,
group_id: int,
op: str = REQ(),
stream_names: list[str] = REQ(json_validator=check_list(check_string)),
*,
group_id: PathOnly[int],
op: str,
stream_names: Json[list[str]],
) -> HttpResponse:
group = access_default_stream_group_by_id(user_profile.realm, group_id)
streams = []
@ -198,9 +187,9 @@ def update_default_stream_group_streams(
@require_realm_admin
@has_request_variables
@typed_endpoint
def remove_default_stream_group(
request: HttpRequest, user_profile: UserProfile, group_id: int
request: HttpRequest, user_profile: UserProfile, *, group_id: PathOnly[int]
) -> HttpResponse:
group = access_default_stream_group_by_id(user_profile.realm, group_id)
do_remove_default_stream_group(user_profile.realm, group)
@ -208,9 +197,9 @@ def remove_default_stream_group(
@require_realm_admin
@has_request_variables
@typed_endpoint
def remove_default_stream(
request: HttpRequest, user_profile: UserProfile, stream_id: int = REQ(json_validator=check_int)
request: HttpRequest, user_profile: UserProfile, *, stream_id: Json[int]
) -> HttpResponse:
(stream, sub) = access_stream_by_id(
user_profile,
@ -221,29 +210,31 @@ def remove_default_stream(
return json_success(request)
@has_request_variables
@typed_endpoint
def update_stream_backend(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int,
description: str | None = REQ(
str_validator=check_capped_string(Stream.MAX_DESCRIPTION_LENGTH), default=None
),
is_private: bool | None = REQ(json_validator=check_bool, default=None),
is_announcement_only: bool | None = REQ(json_validator=check_bool, default=None),
is_default_stream: bool | None = REQ(json_validator=check_bool, default=None),
stream_post_policy: int | None = REQ(
json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES), default=None
),
history_public_to_subscribers: bool | None = REQ(json_validator=check_bool, default=None),
is_web_public: bool | None = REQ(json_validator=check_bool, default=None),
new_name: str | None = REQ(default=None),
message_retention_days: int | str | None = REQ(
json_validator=check_string_or_int, default=None
),
can_remove_subscribers_group_id: int | None = REQ(
"can_remove_subscribers_group", json_validator=check_int, default=None
),
*,
stream_id: PathOnly[int],
description: Annotated[str, StringConstraints(max_length=Stream.MAX_DESCRIPTION_LENGTH)]
| None = None,
is_private: Json[bool] | None = None,
is_announcement_only: Json[bool] | None = None,
is_default_stream: Json[bool] | None = None,
stream_post_policy: Json[
Annotated[
int,
check_int_in_validator(Stream.STREAM_POST_POLICY_TYPES),
]
]
| None = None,
history_public_to_subscribers: Json[bool] | None = None,
is_web_public: Json[bool] | None = None,
new_name: str | None = None,
message_retention_days: Json[str] | Json[int] | None = None,
can_remove_subscribers_group_id: Annotated[
Json[int | None], ApiParamConfig("can_remove_subscribers_group")
] = None,
) -> HttpResponse:
# We allow realm administrators to to update the stream name and
# description even for private streams.
@ -397,11 +388,12 @@ def update_stream_backend(
return json_success(request)
@has_request_variables
@typed_endpoint
def list_subscriptions_backend(
request: HttpRequest,
user_profile: UserProfile,
include_subscribers: bool = REQ(json_validator=check_bool, default=False),
*,
include_subscribers: Json[bool] = False,
) -> HttpResponse:
subscribed, _ = gather_subscriptions(
user_profile,
@ -410,26 +402,32 @@ def list_subscriptions_backend(
return json_success(request, data={"subscriptions": subscribed})
add_subscriptions_schema = check_list(
check_dict_only(
required_keys=[("name", check_string)],
optional_keys=[
("color", check_color),
("description", check_capped_string(Stream.MAX_DESCRIPTION_LENGTH)),
],
),
)
class AddSubscriptionData(BaseModel):
name: str
color: str | None = None
description: (
Annotated[str, StringConstraints(max_length=Stream.MAX_DESCRIPTION_LENGTH)] | None
) = None
remove_subscriptions_schema = check_list(check_string)
@model_validator(mode="after")
def validate_terms(self) -> "AddSubscriptionData":
if self.color is not None:
self.color = check_color("add.color", self.color)
return self
@has_request_variables
@typed_endpoint
def update_subscriptions_backend(
request: HttpRequest,
user_profile: UserProfile,
delete: Sequence[str] = REQ(json_validator=remove_subscriptions_schema, default=[]),
add: Sequence[Mapping[str, str]] = REQ(json_validator=add_subscriptions_schema, default=[]),
*,
delete: Json[list[str]] | None = None,
add: Json[list[AddSubscriptionData]] | None = None,
) -> HttpResponse:
if delete is None:
delete = []
if add is None:
add = []
if not add and not delete:
raise JsonableError(_('Nothing to do. Specify at least one of "add" or "delete".'))
@ -459,17 +457,13 @@ def compose_views(thunks: list[Callable[[], HttpResponse]]) -> dict[str, Any]:
return json_dict
check_principals: Validator[list[str] | list[int]] = check_union(
[check_list(check_string), check_list(check_int)],
)
@has_request_variables
@typed_endpoint
def remove_subscriptions_backend(
request: HttpRequest,
user_profile: UserProfile,
streams_raw: Sequence[str] = REQ("subscriptions", json_validator=remove_subscriptions_schema),
principals: list[str] | list[int] | None = REQ(json_validator=check_principals, default=None),
*,
streams_raw: Annotated[Json[list[str]], ApiParamConfig("subscriptions")],
principals: Json[list[str] | list[int]] | None = None,
) -> HttpResponse:
realm = user_profile.realm
@ -529,42 +523,36 @@ def you_were_just_subscribed_message(
RETENTION_DEFAULT: str | int = "realm_default"
EMPTY_PRINCIPALS: Sequence[str] | Sequence[int] = []
@transaction.atomic(savepoint=False)
@require_non_guest_user
@has_request_variables
@typed_endpoint
def add_subscriptions_backend(
request: HttpRequest,
user_profile: UserProfile,
streams_raw: Sequence[Mapping[str, str]] = REQ(
"subscriptions", json_validator=add_subscriptions_schema
),
invite_only: bool = REQ(json_validator=check_bool, default=False),
is_web_public: bool = REQ(json_validator=check_bool, default=False),
is_default_stream: bool = REQ(json_validator=check_bool, default=False),
stream_post_policy: int = REQ(
json_validator=check_int_in(Stream.STREAM_POST_POLICY_TYPES),
default=Stream.STREAM_POST_POLICY_EVERYONE,
),
history_public_to_subscribers: bool | None = REQ(json_validator=check_bool, default=None),
message_retention_days: str | int = REQ(
json_validator=check_string_or_int, default=RETENTION_DEFAULT
),
can_remove_subscribers_group_id: int | None = REQ(
"can_remove_subscribers_group", json_validator=check_int, default=None
),
announce: bool = REQ(json_validator=check_bool, default=False),
principals: Sequence[str] | Sequence[int] = REQ(
json_validator=check_principals,
default=EMPTY_PRINCIPALS,
),
authorization_errors_fatal: bool = REQ(json_validator=check_bool, default=True),
*,
streams_raw: Annotated[Json[list[AddSubscriptionData]], ApiParamConfig("subscriptions")],
invite_only: Json[bool] = False,
is_web_public: Json[bool] = False,
is_default_stream: Json[bool] = False,
stream_post_policy: Json[
Annotated[int, check_int_in_validator(Stream.STREAM_POST_POLICY_TYPES)]
] = Stream.STREAM_POST_POLICY_EVERYONE,
history_public_to_subscribers: Json[bool] | None = None,
message_retention_days: Json[str] | Json[int] = RETENTION_DEFAULT,
can_remove_subscribers_group_id: Annotated[
Json[int | None], ApiParamConfig("can_remove_subscribers_group")
] = None,
announce: Json[bool] = False,
principals: Json[list[str] | list[int]] | None = None,
authorization_errors_fatal: Json[bool] = True,
) -> HttpResponse:
realm = user_profile.realm
stream_dicts = []
color_map = {}
if principals is None:
principals = []
if can_remove_subscribers_group_id is not None:
permission_configuration = Stream.stream_permission_group_settings[
@ -586,18 +574,18 @@ def add_subscriptions_backend(
is_system_group=True,
)
for stream_dict in streams_raw:
for stream_obj in streams_raw:
# 'color' field is optional
# check for its presence in the streams_raw first
if "color" in stream_dict:
color_map[stream_dict["name"]] = stream_dict["color"]
if stream_obj.color is not None:
color_map[stream_obj.name] = stream_obj.color
stream_dict_copy: StreamDict = {}
stream_dict_copy["name"] = stream_dict["name"].strip()
stream_dict_copy["name"] = stream_obj.name.strip()
# We don't allow newline characters in stream descriptions.
if "description" in stream_dict:
stream_dict_copy["description"] = stream_dict["description"].replace("\n", " ")
if stream_obj.description is not None:
stream_dict_copy["description"] = stream_obj.description.replace("\n", " ")
stream_dict_copy["invite_only"] = invite_only
stream_dict_copy["is_web_public"] = is_web_public
@ -815,11 +803,12 @@ def send_messages_for_new_subscribers(
do_send_messages(notifications, mark_as_read=[user_profile.id])
@has_request_variables
@typed_endpoint
def get_subscribers_backend(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int = REQ("stream", converter=to_non_negative_int, path_only=True),
*,
stream_id: Annotated[NonNegativeInt, ApiParamConfig("stream", path_only=True)],
) -> HttpResponse:
(stream, sub) = access_stream_by_id(
user_profile,
@ -833,16 +822,17 @@ def get_subscribers_backend(
# By default, lists all streams that the user has access to --
# i.e. public streams plus invite-only streams that the user is on
@has_request_variables
@typed_endpoint
def get_streams_backend(
request: HttpRequest,
user_profile: UserProfile,
include_public: bool = REQ(json_validator=check_bool, default=True),
include_web_public: bool = REQ(json_validator=check_bool, default=False),
include_subscribed: bool = REQ(json_validator=check_bool, default=True),
include_all_active: bool = REQ(json_validator=check_bool, default=False),
include_default: bool = REQ(json_validator=check_bool, default=False),
include_owner_subscribed: bool = REQ(json_validator=check_bool, default=False),
*,
include_public: Json[bool] = True,
include_web_public: Json[bool] = False,
include_subscribed: Json[bool] = True,
include_all_active: Json[bool] = False,
include_default: Json[bool] = False,
include_owner_subscribed: Json[bool] = False,
) -> HttpResponse:
streams = do_get_streams(
user_profile,
@ -856,11 +846,12 @@ def get_streams_backend(
return json_success(request, data={"streams": streams})
@has_request_variables
@typed_endpoint
def get_stream_backend(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int,
*,
stream_id: PathOnly[int],
) -> HttpResponse:
(stream, sub) = access_stream_by_id(user_profile, stream_id, allow_realm_admin=True)
@ -868,11 +859,12 @@ def get_stream_backend(
return json_success(request, data={"stream": stream_to_dict(stream, recent_traffic)})
@has_request_variables
@typed_endpoint
def get_topics_backend(
request: HttpRequest,
maybe_user_profile: UserProfile | AnonymousUser,
stream_id: int = REQ(converter=to_non_negative_int, path_only=True),
*,
stream_id: PathOnly[NonNegativeInt],
) -> HttpResponse:
if not maybe_user_profile.is_authenticated:
is_web_public_query = True
@ -906,12 +898,13 @@ def get_topics_backend(
@require_realm_admin
@has_request_variables
@typed_endpoint
def delete_in_topic(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int = REQ(converter=to_non_negative_int, path_only=True),
topic_name: str = REQ("topic_name"),
*,
stream_id: PathOnly[NonNegativeInt],
topic_name: str,
) -> HttpResponse:
stream, ignored_sub = access_stream_by_id(user_profile, stream_id)
@ -943,43 +936,70 @@ def delete_in_topic(
return json_success(request, data={"complete": True})
@has_request_variables
@typed_endpoint
def json_get_stream_id(
request: HttpRequest, user_profile: UserProfile, stream_name: str = REQ("stream")
request: HttpRequest,
user_profile: UserProfile,
*,
stream_name: Annotated[str, ApiParamConfig("stream")],
) -> HttpResponse:
(stream, sub) = access_stream_by_name(user_profile, stream_name)
return json_success(request, data={"stream_id": stream.id})
@has_request_variables
class SubscriptionPropertyChangeRequest(BaseModel):
stream_id: int
property: str
value: bool | str
@model_validator(mode="after")
def validate_terms(self) -> "SubscriptionPropertyChangeRequest":
boolean_properties = {
"in_home_view",
"is_muted",
"desktop_notifications",
"audible_notifications",
"push_notifications",
"email_notifications",
"pin_to_top",
"wildcard_mentions_notify",
}
if self.property == "color":
self.value = check_color("color", self.value)
elif self.property in boolean_properties:
if not isinstance(self.value, bool):
raise JsonableError(_("{property} is not a boolean").format(property=self.property))
else:
raise JsonableError(
_("Unknown subscription property: {property}").format(property=self.property)
)
return self
@typed_endpoint
def update_subscriptions_property(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int = REQ(json_validator=check_int),
property: str = REQ(),
value: str = REQ(),
*,
stream_id: PathOnly[Json[int]],
property: str,
value: Annotated[Json[bool] | str, Field(union_mode="left_to_right")],
) -> HttpResponse:
subscription_data = [{"property": property, "stream_id": stream_id, "value": value}]
change_request = SubscriptionPropertyChangeRequest(
stream_id=stream_id, property=property, value=value
)
return update_subscription_properties_backend(
request, user_profile, subscription_data=subscription_data
request, user_profile, subscription_data=[change_request]
)
@has_request_variables
@typed_endpoint
def update_subscription_properties_backend(
request: HttpRequest,
user_profile: UserProfile,
subscription_data: list[dict[str, Any]] = REQ(
json_validator=check_list(
check_dict(
[
("stream_id", check_int),
("property", check_string),
("value", check_union([check_string, check_bool])),
]
),
),
),
*,
subscription_data: Json[list[SubscriptionPropertyChangeRequest]],
) -> HttpResponse:
"""
This is the entry point to changing subscription properties. This
@ -991,27 +1011,11 @@ def update_subscription_properties_backend(
[{"stream_id": "1", "property": "is_muted", "value": False},
{"stream_id": "1", "property": "color", "value": "#c2c2c2"}]
"""
property_converters = {
"color": check_color,
"in_home_view": check_bool,
"is_muted": check_bool,
"desktop_notifications": check_bool,
"audible_notifications": check_bool,
"push_notifications": check_bool,
"email_notifications": check_bool,
"pin_to_top": check_bool,
"wildcard_mentions_notify": check_bool,
}
for change in subscription_data:
stream_id = change["stream_id"]
property = change["property"]
value = change["value"]
if property not in property_converters:
raise JsonableError(
_("Unknown subscription property: {property}").format(property=property)
)
stream_id = change.stream_id
property = change.property
value = change.value
(stream, sub) = access_stream_by_id(user_profile, stream_id)
if sub is None:
@ -1019,11 +1023,6 @@ def update_subscription_properties_backend(
_("Not subscribed to channel ID {channel_id}").format(channel_id=stream_id)
)
try:
value = property_converters[property](property, value)
except ValidationError as error:
raise JsonableError(error.message)
do_change_subscription_property(
user_profile, sub, stream, property, value, acting_user=user_profile
)
@ -1031,11 +1030,12 @@ def update_subscription_properties_backend(
return json_success(request)
@has_request_variables
@typed_endpoint
def get_stream_email_address(
request: HttpRequest,
user_profile: UserProfile,
stream_id: int = REQ("stream", converter=to_non_negative_int, path_only=True),
*,
stream_id: Annotated[NonNegativeInt, ApiParamConfig("stream", path_only=True)],
) -> HttpResponse:
(stream, sub) = access_stream_by_id(
user_profile,