1import json2import re3from dataclasses import dataclass4from typing import Any567MAX_REQUEST_BYTES = 1_048_5768MAX_SOURCE_LENGTH = 524_2889MAX_OPTION_COUNT = 6410OPTION_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)11BACKGROUND = re.compile(r"^#[0-9a-f]{6}$", re.IGNORECASE)121314class ProtocolError(Exception):15 def __init__(self, status: int, code: str, message: str):16 super().__init__(message)17 self.status = status18 self.code = code19202122class RenderInput:23 source: str24 options: dict[str, str]25 title: str26 description: str27 background: str28 padding: int29 frame: bool303132def _object(value: Any, name: str) -> dict[str, Any]:33 if not isinstance(value, dict):34 raise ProtocolError(400, "invalid_request", f"{name} must be an object.")35 return value363738def _string(value: Any, name: str, maximum: int) -> str:39 if not isinstance(value, str):40 raise ProtocolError(400, "invalid_request", f"{name} must be a string.")41 if len(value) > maximum:42 raise ProtocolError(413, "request_too_large", f"{name} is too large.")43 return value444546def parse_render_input(body: str) -> RenderInput:47 if len(body.encode("utf-8")) > MAX_REQUEST_BYTES:48 raise ProtocolError(413, "request_too_large", "Render request is too large.")49 try:50 request = _object(json.loads(body), "request")51 except ProtocolError:52 raise53 except (json.JSONDecodeError, UnicodeError) as error:54 raise ProtocolError(400, "invalid_json", "Render request is not valid JSON.") from error5556 if "engine" in request:57 raise ProtocolError(400, "invalid_request", "Renderer unit requests must not select an engine.")58 if request.get("format", "svg") != "svg":59 raise ProtocolError(400, "unsupported_format", "The rendering plane currently supports SVG only.")60 source = _string(request.get("source"), "source", MAX_SOURCE_LENGTH)61 if not source.strip():62 raise ProtocolError(400, "empty_source", "Diagram source cannot be empty.")6364 raw_options = _object(request.get("options", {}), "options")65 if len(raw_options) > MAX_OPTION_COUNT:66 raise ProtocolError(400, "invalid_options", f"options cannot contain more than {MAX_OPTION_COUNT} entries.")67 options: dict[str, str] = {}68 for name, value in raw_options.items():69 if not isinstance(name, str) or not OPTION_NAME.fullmatch(name):70 raise ProtocolError(400, "invalid_options", f"Invalid renderer option: {name}.")71 if not isinstance(value, (str, int, float, bool)) or isinstance(value, (dict, list)):72 raise ProtocolError(400, "invalid_options", f"Renderer option {name} must be a string, number, or boolean.")73 normalized = str(value).lower() if isinstance(value, bool) else str(value)74 if len(normalized) > 4_096:75 raise ProtocolError(400, "invalid_options", f"Renderer option {name} is too large.")76 options[name.lower()] = normalized7778 metadata = _object(request.get("metadata", {}), "metadata")79 title = _string(metadata.get("title", ""), "metadata.title", 200)80 description = _string(metadata.get("description", ""), "metadata.description", 2_000)8182 presentation = _object(request.get("presentation", {}), "presentation")83 background = _string(presentation.get("background", ""), "presentation.background", 7)84 padding = presentation.get("padding", 0)85 frame = presentation.get("frame", False)86 if background and not BACKGROUND.fullmatch(background):87 raise ProtocolError(400, "invalid_presentation", "presentation.background must be an RGB hex color.")88 if not isinstance(padding, int) or isinstance(padding, bool) or not 0 <= padding <= 256:89 raise ProtocolError(400, "invalid_presentation", "presentation.padding must be an integer from 0 to 256.")90 if not isinstance(frame, bool):91 raise ProtocolError(400, "invalid_presentation", "presentation.frame must be a boolean.")9293 return RenderInput(source, options, title, description, background, padding, frame)