1import json2import re3from dataclasses import dataclass4from typing import Any56MAX_REQUEST_BYTES = 1_048_5767MAX_SOURCE_LENGTH = 524_2888MAX_OPTION_COUNT = 649OPTION_NAME = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$', re.IGNORECASE)101112class ProtocolError(Exception):13 def __init__(self, status, code, message):14 super().__init__(message)15 self.status, self.code = status, code16171819class RenderInput:20 source: str21 options: dict22 title: str23 description: str24 background: str25 padding: int26 frame: bool272829def _object(value: Any, name: str) -> dict:30 if not isinstance(value, dict):31 raise ProtocolError(400, 'invalid_request', f'{name} must be an object.')32 return value333435def _string(value: Any, name: str, maximum: int) -> str:36 if not isinstance(value, str):37 raise ProtocolError(400, 'invalid_request', f'{name} must be a string.')38 if len(value) > maximum:39 raise ProtocolError(413, 'request_too_large', f'{name} is too large.')40 return value414243def parse_render_input(body: str) -> RenderInput:44 if len(body.encode()) > MAX_REQUEST_BYTES:45 raise ProtocolError(413, 'request_too_large', 'Render request is too large.')46 try:47 request = _object(json.loads(body), 'request')48 except (json.JSONDecodeError, UnicodeError) as error:49 raise ProtocolError(400, 'invalid_json', 'Render request is not valid JSON.') from error50 if 'engine' in request:51 raise ProtocolError(400, 'invalid_request', 'Renderer unit requests must not select an engine.')52 if request.get('format', 'svg') != 'svg':53 raise ProtocolError(400, 'unsupported_format', 'The rendering plane currently supports SVG only.')54 source = _string(request.get('source'), 'source', MAX_SOURCE_LENGTH)55 if not source.strip():56 raise ProtocolError(400, 'empty_source', 'Diagram source cannot be empty.')57 raw_options = _object(request.get('options', {}), 'options')58 if len(raw_options) > MAX_OPTION_COUNT:59 raise ProtocolError(400, 'invalid_options', f'options cannot contain more than {MAX_OPTION_COUNT} entries.')60 options = {}61 for name, value in raw_options.items():62 if not isinstance(name, str) or not OPTION_NAME.fullmatch(name):63 raise ProtocolError(400, 'invalid_options', f'Invalid renderer option: {name}.')64 if not isinstance(value, (str, int, float, bool)) or isinstance(value, (dict, list)):65 raise ProtocolError(400, 'invalid_options', f'Renderer option {name} must be a string, number, or boolean.')66 normalized = str(value).lower() if isinstance(value, bool) else str(value)67 if len(normalized) > 4_096:68 raise ProtocolError(400, 'invalid_options', f'Renderer option {name} is too large.')69 options[name.lower()] = normalized70 metadata = _object(request.get('metadata', {}), 'metadata')71 title = _string(metadata.get('title', ''), 'metadata.title', 200)72 description = _string(metadata.get('description', ''), 'metadata.description', 2_000)73 presentation = _object(request.get('presentation', {}), 'presentation')74 background = _string(presentation.get('background', ''), 'presentation.background', 7)75 if background and not (len(background) == 7 and background[0] == '#' and all(c in '0123456789abcdefABCDEF' for c in background[1:])):76 raise ProtocolError(400, 'invalid_presentation', 'presentation.background must be an RGB hex color.')77 padding = presentation.get('padding', 0)78 frame = presentation.get('frame', False)79 if not isinstance(padding, int) or isinstance(padding, bool) or not 0 <= padding <= 256:80 raise ProtocolError(400, 'invalid_presentation', 'presentation.padding must be an integer from 0 to 256.')81 if not isinstance(frame, bool):82 raise ProtocolError(400, 'invalid_presentation', 'presentation.frame must be a boolean.')83 return RenderInput(source, options, title, description, background, padding, frame)