clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / python / symbolator / src / protocol.py
protocol.py 4.7K
1import json2import math3import re4from dataclasses import dataclass5from typing import Any67MAX_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)12KNOWN_OPTIONS = {"component", "transparent", "title", "scale", "no-type", "library-name"}131415class ProtocolError(Exception):16 def __init__(self, status: int, code: str, message: str):17 super().__init__(message)18 self.status = status19 self.code = code202122@dataclass(frozen=True)23class RenderInput:24 source: str25 options: dict[str, str]26 title: str27 description: str28 background: str29 padding: int30 frame: bool313233def _object(value: Any, name: str) -> dict[str, Any]:34 if not isinstance(value, dict):35 raise ProtocolError(400, "invalid_request", f"{name} must be an object.")36 return value373839def _string(value: Any, name: str, maximum: int) -> str:40 if not isinstance(value, str):41 raise ProtocolError(400, "invalid_request", f"{name} must be a string.")42 if len(value) > maximum:43 raise ProtocolError(413, "request_too_large", f"{name} is too large.")44 return value454647def parse_render_input(body: str) -> RenderInput:48 if len(body.encode("utf-8")) > MAX_REQUEST_BYTES:49 raise ProtocolError(413, "request_too_large", "Render request is too large.")50 try:51 value = _object(json.loads(body), "request")52 except ProtocolError:53 raise54 except (json.JSONDecodeError, UnicodeError) as error:55 raise ProtocolError(400, "invalid_json", "Render request is not valid JSON.") from error56 if "engine" in value:57 raise ProtocolError(400, "invalid_request", "Renderer unit requests must not select an engine.")58 if value.get("format", "svg") != "svg":59 raise ProtocolError(400, "unsupported_format", "The rendering plane currently supports SVG only.")60 source = _string(value.get("source"), "source", MAX_SOURCE_LENGTH)61 if not source.strip():62 raise ProtocolError(400, "empty_source", "Diagram source cannot be empty.")63 raw_options = _object(value.get("options", {}), "options")64 if len(raw_options) > MAX_OPTION_COUNT:65 raise ProtocolError(400, "invalid_options", "Too many renderer options.")66 options: dict[str, str] = {}67 for name, option in raw_options.items():68 if not isinstance(name, str) or not OPTION_NAME.fullmatch(name):69 raise ProtocolError(400, "invalid_options", f"Invalid renderer option: {name}.")70 if name.lower() not in KNOWN_OPTIONS:71 raise ProtocolError(400, "invalid_options", f"Unknown renderer option: {name}.")72 if not isinstance(option, (str, int, float, bool)) or isinstance(option, (dict, list)):73 raise ProtocolError(400, "invalid_options", f"Renderer option {name} must be scalar.")74 normalized = str(option).lower() if isinstance(option, bool) else str(option)75 if len(normalized) > 4096:76 raise ProtocolError(400, "invalid_options", f"Renderer option {name} is too large.")77 options[name.lower()] = normalized78 if "scale" in options:79 try:80 scale = float(options["scale"])81 except ValueError as error:82 raise ProtocolError(400, "invalid_options", "options.scale must be numeric.") from error83 if not math.isfinite(scale) or not 0 < scale <= 16:84 raise ProtocolError(400, "invalid_options", "options.scale must be finite and between 0 and 16.")85 if "library-name" in options and "title" not in options:86 raise ProtocolError(400, "invalid_options", "options.library-name requires the title option.")87 metadata = _object(value.get("metadata", {}), "metadata")88 title = _string(metadata.get("title", ""), "metadata.title", 200)89 description = _string(metadata.get("description", ""), "metadata.description", 2000)90 presentation = _object(value.get("presentation", {}), "presentation")91 background = _string(presentation.get("background", ""), "presentation.background", 7)92 if background and not BACKGROUND.fullmatch(background):93 raise ProtocolError(400, "invalid_presentation", "presentation.background must be an RGB hex color.")94 padding = presentation.get("padding", 0)95 frame = presentation.get("frame", False)96 if not isinstance(padding, int) or isinstance(padding, bool) or not 0 <= padding <= 256:97 raise ProtocolError(400, "invalid_presentation", "presentation.padding must be an integer from 0 to 256.")98 if not isinstance(frame, bool):99 raise ProtocolError(400, "invalid_presentation", "presentation.frame must be a boolean.")100 return RenderInput(source, options, title, description, background, padding, frame)