OpenSlides/openslides/utils/websocket.py

231 lines
7.7 KiB
Python

import json
from typing import Any, Dict, Optional
import jsonschema
import lz4.frame
from channels.generic.websocket import AsyncWebsocketConsumer
from django.conf import settings
from websockets.exceptions import ConnectionClosed
from .stats import WebsocketThroughputLogger
# Custom Websocket error codes (not to be confused with the websocket *connection*
# status codes like 1000 or 1006. These are custom ones for OpenSlides to give a
# machine parseable response, so the client can react on errors.
WEBSOCKET_NOT_AUTHORIZED = 100
# E.g. if a user does not have the right permission(s) for a message.
WEBSOCKET_CHANGE_ID_TOO_HIGH = 101
# If data is requested and the given change id is higher than the highest change id
# from the element_cache.
WEBSOCKET_WRONG_FORMAT = 102
# If the recieved data has not the expected format.
class AsyncCompressedJsonWebsocketConsumer(AsyncWebsocketConsumer):
async def receive(
self,
text_data: Optional[str] = None,
bytes_data: Optional[bytes] = None,
**kwargs: Dict[str, Any],
) -> None:
if bytes_data:
uncompressed_data = lz4.frame.decompress(bytes_data)
text_data = uncompressed_data.decode("utf-8")
recv_len = len(bytes_data)
uncompressed_len = len(uncompressed_data)
await WebsocketThroughputLogger.receive(uncompressed_len, recv_len)
elif text_data:
uncompressed_len = len(text_data.encode("utf-8"))
await WebsocketThroughputLogger.receive(uncompressed_len)
if text_data:
await self.receive_json(json.loads(text_data), **kwargs)
async def send_json(self, content: Any, close: bool = False) -> None:
text_data = json.dumps(content)
bytes_data = None # type: ignore
b_text_data = text_data.encode("utf-8")
uncompressed_len = len(b_text_data)
if getattr(settings, "COMPRESSION", True):
compressed_data = lz4.frame.compress(b_text_data)
ratio = len(b_text_data) / len(compressed_data)
if ratio > 1:
bytes_data = compressed_data
text_data = None # type: ignore
await WebsocketThroughputLogger.send(uncompressed_len, len(bytes_data))
if not bytes_data:
await WebsocketThroughputLogger.send(uncompressed_len)
await self.send(text_data=text_data, bytes_data=bytes_data, close=close)
async def receive_json(self, content: str, **kwargs: Dict[str, Any]) -> None:
pass
class ProtocollAsyncJsonWebsocketConsumer(AsyncCompressedJsonWebsocketConsumer):
"""
Mixin for JSONWebsocketConsumers, that speaks the a special protocol.
"""
async def send_json( # type: ignore
self,
type: str,
content: Any,
id: Optional[str] = None,
in_response: Optional[str] = None,
silence_errors: Optional[bool] = True,
) -> None:
"""
Sends the data with the type.
If silence_errors is True (default), all ConnectionClosed
and runtime errors during sending will be ignored.
"""
out = {"type": type, "content": content}
if id:
out["id"] = id
if in_response:
out["in_response"] = in_response
try:
await super().send_json(out)
except (ConnectionClosed, RuntimeError) as e:
# The ConnectionClosed error is thrown by the websocket lib: websocket/protocol.py in ensure_open
# `websockets.exceptions.ConnectionClosed: WebSocket connection is closed: code = 1005
# (no status code [internal]), no reason` (Also with other codes)
# The RuntimeError is thrown by uvicorn: uvicorn/protocols/websockets/websockets_impl.py in asgi_send
# `RuntimeError: Unexpected ASGI message 'websocket.send', after sending 'websocket.close'`
if not silence_errors:
raise e
async def send_error(
self,
code: int,
message: str,
in_response: Optional[str] = None,
silence_errors: Optional[bool] = True,
) -> None:
"""
Send generic error messages with a custom status code (see above) and a text message.
"""
await self.send_json(
"error",
{"code": code, "message": message},
None,
in_response=in_response,
silence_errors=silence_errors,
)
async def receive_json(self, content: Any) -> None: # type: ignore
"""
Receives the json data, parses it and calls receive_content.
"""
try:
jsonschema.validate(content, schema)
except jsonschema.ValidationError as err:
try:
in_response = content["id"]
except (TypeError, KeyError):
# content is not a dict (TypeError) or has not the key id (KeyError)
in_response = None
await self.send_error(
code=WEBSOCKET_WRONG_FORMAT, message=str(err), in_response=in_response
)
return
await websocket_client_messages[content["type"]].receive_content(
self, content["content"], id=content["id"]
)
schema: Dict[str, Any] = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OpenSlidesWebsocketProtocol",
"description": "The packages that OpenSlides sends between the server and the client.",
"type": "object",
"properties": {
"type": {
"description": "Defines what kind of packages is packed.",
"type": "string",
},
"content": {"description": "The content of the package."},
"id": {"description": "An identifier of the package.", "type": "string"},
"in_response": {
"description": "The id of another package that the other part sent before.",
"type": "string",
},
},
"required": ["type", "content", "id"],
"anyOf": [], # This will be filled in register_client_message()
}
class BaseWebsocketClientMessage:
schema: Dict[str, object] = {}
"""
Optional schema.
If schema is not set, any value in content is accepted.
"""
identifier: str = ""
"""
A unique identifier for the websocket message.
This is used as value in the 'type' property in the websocket message.
"""
content_required = True
"""
Desiedes, if the content property is required.
"""
async def receive_content(
self, consumer: "ProtocollAsyncJsonWebsocketConsumer", message: Any, id: str
) -> None:
raise NotImplementedError(
"WebsocketClientMessage needs the method receive_content()."
)
websocket_client_messages: Dict[str, BaseWebsocketClientMessage] = {}
"""
Saves all websocket client message object ordered by there identifier.
"""
def register_client_message(
websocket_client_message: BaseWebsocketClientMessage,
) -> None:
"""
Registers one websocket client message class.
"""
if (
not websocket_client_message.identifier
or websocket_client_message.identifier in websocket_client_messages
):
raise NotImplementedError("WebsocketClientMessage needs a unique identifier.")
websocket_client_messages[
websocket_client_message.identifier
] = websocket_client_message
# Add the message schema to the schema
message_schema: Dict[str, Any] = {
"properties": {
"type": {"const": websocket_client_message.identifier},
"content": websocket_client_message.schema,
}
}
if websocket_client_message.content_required:
message_schema["required"] = ["content"]
schema["anyOf"].append(message_schema)