Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Initial spaces summary API #9643

Merged
merged 11 commits into from
Mar 18, 2021
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/9643.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add initial experimental support for a "space summary" API.
6 changes: 6 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ class EventTypes:

Dummy = "org.matrix.dummy_event"

MSC1772_SPACE_CHILD = "org.matrix.msc1772.space.child"
MSC1772_SPACE_PARENT = "org.matrix.msc1772.space.parent"


class EduTypes:
Presence = "m.presence"
Expand Down Expand Up @@ -160,6 +163,9 @@ class EventContentFields:
# cf https://github.com/matrix-org/matrix-doc/pull/2228
SELF_DESTRUCT_AFTER = "org.matrix.self_destruct_after"

# cf https://github.com/matrix-org/matrix-doc/pull/1772
MSC1772_ROOM_TYPE = "org.matrix.msc1772.type"


class RoomEncryptionAlgorithms:
MEGOLM_V1_AES_SHA2 = "m.megolm.v1.aes-sha2"
Expand Down
6 changes: 6 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ def read_config(self, config: JsonDict, **kwargs):

# MSC2858 (multiple SSO identity providers)
self.msc2858_enabled = experimental.get("msc2858_enabled", False) # type: bool

# MSC2946 (space summaries)
self.msc2946_enabled = experimental.get("msc2946_enabled", False) # type: bool

# MSC1772 (spaces)
self.msc1772_enabled = experimental.get("msc1772_enabled", False) # type: bool
richvdh marked this conversation as resolved.
Show resolved Hide resolved
202 changes: 202 additions & 0 deletions synapse/handlers/space_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# -*- coding: utf-8 -*-
# Copyright 2021 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import itertools
import logging
from collections import deque
from typing import TYPE_CHECKING, Iterable, List, Optional, Set, Tuple

from synapse.api.constants import EventContentFields, EventTypes, HistoryVisibility
from synapse.api.errors import AuthError
from synapse.events import EventBase
from synapse.events.utils import format_event_for_client_v2
from synapse.types import JsonDict

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)

# number of rooms to return. We'll stop once we hit this limit.
# TODO: allow clients to reduce this with a request param.
ROOMS_LIMIT = 50

# max number of events to return per room.
ROOMS_PER_SPACE_LIMIT = 50
richvdh marked this conversation as resolved.
Show resolved Hide resolved


class SpaceSummaryHandler:
def __init__(self, hs: "HomeServer"):
self._clock = hs.get_clock()
self._auth = hs.get_auth()
self._room_list_handler = hs.get_room_list_handler()
self._state_handler = hs.get_state_handler()
self._store = hs.get_datastore()
self._msc1772 = hs.config.experimental.msc1772_enabled
self._event_serializer = hs.get_event_client_serializer()

async def get_space_summary(
self,
requester: str,
room_id: str,
suggested_only: bool = False,
max_rooms_per_space: Optional[int] = None,
) -> JsonDict:
"""
Implementation of the space summary API

Args:
requester: user id of the user making this request

room_id: room id to start the summary at

suggested_only: whether we should only return children with the "suggested"
flag set.

max_rooms_per_space: an optional limit on the number of child rooms we will
return. This does not apply to the root room (ie, room_id), and
is overridden by ROOMS_PER_SPACE_LIMIT.

Returns:
summary dict to return
"""
# first of all, check that the user is in the room in question (or it's
# world-readable)
await self._auth.check_user_in_room_or_world_readable(room_id, requester)

# the queue of rooms to process
room_queue = deque((room_id,))

processed_rooms = set() # type: Set[str]

rooms_result = [] # type: List[JsonDict]
events_result = [] # type: List[JsonDict]

now = self._clock.time_msec()

while room_queue and len(rooms_result) < ROOMS_LIMIT:
room_id = room_queue.popleft()
logger.debug("Processing room %s", room_id)
processed_rooms.add(room_id)

try:
await self._auth.check_user_in_room_or_world_readable(
room_id, requester
)
except AuthError:
logger.info(
"user %s cannot view room %s, omitting from summary",
requester,
room_id,
)
continue

room_entry = await self._build_room_entry(room_id)
rooms_result.append(room_entry)

# look for child rooms/spaces.
child_events = await self._get_child_events(room_id)

if suggested_only:
# we only care about suggested children
child_events = filter(_is_suggested_child_event, child_events)
clokep marked this conversation as resolved.
Show resolved Hide resolved

# if this is not the first room, and the client has specified a limit,
# apply it (the client limit does not apply to the root room)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "first room" mean here? (How is it different from "root room"?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh, they mean the same thing - the room_id that is passed in the request, which we start walking from. I'll try to clarify the comment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I initially thought but then second guessed myself. Thanks for clarifying!

if max_rooms_per_space is not None and len(processed_rooms) > 1:
max_rooms = min(ROOMS_PER_SPACE_LIMIT, max_rooms_per_space)
else:
max_rooms = ROOMS_PER_SPACE_LIMIT

for edge_event in itertools.islice(child_events, max_rooms):
edge_room_id = edge_event.state_key

events_result.append(
await self._event_serializer.serialize_event(
edge_event,
time_now=now,
event_format=format_event_for_client_v2,
)
)

# if we haven't yet visited the target of this link, add it to the queue
if edge_room_id not in processed_rooms:
room_queue.append(edge_room_id)

return {"rooms": rooms_result, "events": events_result}

async def _build_room_entry(self, room_id: str) -> JsonDict:
"""Generate en entry suitable for the 'rooms' list in the summary response"""
stats = await self._store.get_room_with_stats(room_id)

# currently this should be impossible because we call
# check_user_in_room_or_world_readable on the room before we get here, so
# there should always be an entry
assert stats is not None, "unable to retrieve stats for %s" % (room_id,)

current_state_ids = await self._store.get_current_state_ids(room_id)
create_event = await self._store.get_event(
current_state_ids[(EventTypes.Create, "")]
)

room_type = None
if self._msc1772:
room_type = create_event.content.get(EventContentFields.MSC1772_ROOM_TYPE)

entry = {
"room_id": stats["room_id"],
"name": stats["name"],
"topic": stats["topic"],
"canonical_alias": stats["canonical_alias"],
"num_joined_members": stats["joined_members"],
"avatar_url": stats["avatar"],
"world_readable": (
stats["history_visibility"] == HistoryVisibility.WORLD_READABLE
),
"guest_can_join": stats["guest_access"] == "can_join",
"room_type": room_type,
}

# Filter out Nones – rather omit the field altogether
room_entry = {k: v for k, v in entry.items() if v is not None}

return room_entry

async def _get_child_events(self, room_id: str) -> Iterable[EventBase]:
clokep marked this conversation as resolved.
Show resolved Hide resolved
# look for child rooms/spaces.
current_state_ids = await self._store.get_current_state_ids(room_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like there should be an easier way to pull the state of a room filtering for the event type (instead of having to do it after the fact). It seems we only have methods for filtering by the type + state key though, which doesn't help here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree there should, but it's not a can of worms I want to open right this minute.


edge_event_types = () # type: Tuple[str, ...]
if self._msc1772:
edge_event_types += (EventTypes.MSC1772_SPACE_CHILD,)

events = await self._store.get_events_as_list(
[
event_id
for key, event_id in current_state_ids.items()
if key[0] in edge_event_types
]
)

# filter out any events without a "via" (which implies it has been redacted)
return (e for e in events if e.content.get("via"))


def _is_suggested_child_event(edge_event: EventBase) -> bool:
suggested = edge_event.content.get("suggested")
if isinstance(suggested, bool) and suggested:
return True
logger.debug("Ignorning not-suggested child %s", edge_event.state_key)
return False
55 changes: 54 additions & 1 deletion synapse/rest/client/v1/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
from typing import TYPE_CHECKING, List, Optional
from urllib import parse as urlparse

from twisted.web.server import Request

from synapse.api.constants import EventTypes, Membership
from synapse.api.errors import (
AuthError,
Expand All @@ -35,6 +37,7 @@
from synapse.http.servlet import (
RestServlet,
assert_params_in_dict,
parse_boolean,
parse_integer,
parse_json_object_from_request,
parse_string,
Expand Down Expand Up @@ -981,7 +984,54 @@ def register_txn_path(servlet, regex_string, http_server, with_get=False):
)


def register_servlets(hs, http_server, is_worker=False):
class RoomSpaceSummaryRestServlet(RestServlet):
PATTERNS = (
re.compile(
"^/_matrix/client/unstable/org.matrix.msc2946"
"/rooms/(?P<room_id>[^/]*)/spaces$"
),
)

def __init__(self, hs: "synapse.server.HomeServer"):
super().__init__()
self._auth = hs.get_auth()
self._space_summary_handler = hs.get_space_summary_handler()

async def on_GET(self, request: Request, room_id: str):
richvdh marked this conversation as resolved.
Show resolved Hide resolved
requester = await self._auth.get_user_by_req(request, allow_guest=True)

return 200, await self._space_summary_handler.get_space_summary(
requester.user.to_string(),
room_id,
suggested_only=parse_boolean(request, "suggested_only", default=False),
max_rooms_per_space=parse_integer(request, "max_rooms_per_space"),
)

async def on_POST(self, request: Request, room_id: str):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async def on_POST(self, request: Request, room_id: str):
async def on_POST(self, request: Request, room_id: str) -> Tuple[int, JsonDict]:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the same API, just using a JSON object for parameters instead of query parameters? 😕

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, exactly.

MSC2946 proposes only a POST API, and that's what the clients currently use.

I'm unconvinced it should be a POST api, so have also added a GET API.

Copy link
Member

@clokep clokep Mar 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like it should be a GET API, I'm not sure if having both is less confusing though. 😄

I guess it is all unstable though so we can drop it whenever.

requester = await self._auth.get_user_by_req(request, allow_guest=True)
content = parse_json_object_from_request(request)

suggested_only = content.get("suggested_only", False)
if not isinstance(suggested_only, bool):
raise SynapseError(
400, "'suggested_only' must be a boolean", Codes.BAD_JSON
)

max_rooms_per_space = content.get("max_rooms_per_space")
if max_rooms_per_space is not None and not isinstance(max_rooms_per_space, int):
raise SynapseError(
400, "'max_rooms_per_space' must be an integer", Codes.BAD_JSON
)

return 200, await self._space_summary_handler.get_space_summary(
requester.user.to_string(),
room_id,
suggested_only=suggested_only,
max_rooms_per_space=max_rooms_per_space,
)


def register_servlets(hs: "synapse.server.HomeServer", http_server, is_worker=False):
RoomStateEventRestServlet(hs).register(http_server)
RoomMemberListRestServlet(hs).register(http_server)
JoinedRoomMemberListRestServlet(hs).register(http_server)
Expand All @@ -995,6 +1045,9 @@ def register_servlets(hs, http_server, is_worker=False):
RoomTypingRestServlet(hs).register(http_server)
RoomEventContextServlet(hs).register(http_server)

if hs.config.experimental.msc2946_enabled:
RoomSpaceSummaryRestServlet(hs).register(http_server)

# Some servlets only get registered for the main process.
if not is_worker:
RoomCreateRestServlet(hs).register(http_server)
Expand Down
5 changes: 5 additions & 0 deletions synapse/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
from synapse.handlers.room_member_worker import RoomMemberWorkerHandler
from synapse.handlers.search import SearchHandler
from synapse.handlers.set_password import SetPasswordHandler
from synapse.handlers.space_summary import SpaceSummaryHandler
from synapse.handlers.sso import SsoHandler
from synapse.handlers.stats import StatsHandler
from synapse.handlers.sync import SyncHandler
Expand Down Expand Up @@ -725,6 +726,10 @@ def get_module_api(self) -> ModuleApi:
def get_account_data_handler(self) -> AccountDataHandler:
return AccountDataHandler(self)

@cache_in_self
def get_space_summary_handler(self) -> SpaceSummaryHandler:
return SpaceSummaryHandler(self)

@cache_in_self
def get_external_cache(self) -> ExternalCache:
return ExternalCache(self)
Expand Down