Source code for litestar_vite.codegen._asyncapi

"""AsyncAPI plugin discovery and document normalization for Litestar."""

import contextlib
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import unquote

import msgspec

from litestar_vite.typing import ASYNCAPI_INSTALLED

if TYPE_CHECKING:
    from litestar import Litestar

ASYNCAPI_DOCS_DEFAULT_PATH = "/asyncapi"
_NON_ALNUM_RE = re.compile(r"[^A-Za-z0-9]+")


def _slug_segment(segment: str) -> str:
    """Sanitize a path segment for use in an AsyncAPI channel key.

    Path parameter segments shaped {name} or {name:converter} return 'p_' followed
    by the sanitized parameter name. Any other segment has every run of characters
    outside [A-Za-z0-9] replaced by a single underscore.

    Args:
        segment: Path segment string.

    Returns:
        Sanitized segment string.
    """
    if segment.startswith("{") and segment.endswith("}"):
        inner = segment[1:-1]
        name = inner.split(":", 1)[0]
        sanitized = _NON_ALNUM_RE.sub("_", name)
        return f"p_{sanitized}"
    return _NON_ALNUM_RE.sub("_", segment)


def _channel_key_base(normalized_address: str) -> str:
    """Build a deterministic base channel key from a route address.

    Strips the leading slash, splits on slash, drops empty segments, applies
    _slug_segment to each, and joins with double underscores. An empty result
    returns 'root'.

    Args:
        normalized_address: The normalized route path.

    Returns:
        Deterministic base channel key.
    """
    segments = [_slug_segment(s) for s in normalized_address.lstrip("/").split("/") if s]
    return "__".join(segments) or "root"


@dataclass(slots=True)
class _ChannelKeyAllocator:
    """Allocate collision-safe channel keys across realtime sources."""

    assigned: dict[tuple[str, str], str] = field(default_factory=dict[tuple[str, str], str])
    taken: set[str] = field(default_factory=set[str])

    def allocate(self, normalized_address: str, source: str) -> str:
        """Allocate a unique channel key for a given route address and source.

        Two different sources at the same address deliberately receive different
        channel keys to prevent collisions and ensure intact operations.

        Args:
            normalized_address: The normalized route address string.
            source: Realtime source identifier ('websocket', 'channels', or 'sse').

        Returns:
            Collision-safe unique channel key.
        """
        pair = (normalized_address, source)
        if pair in self.assigned:
            return self.assigned[pair]

        base = _channel_key_base(normalized_address)
        candidate = base
        counter = 2
        while candidate in self.taken:
            candidate = f"{base}_{counter}"
            counter += 1

        self.taken.add(candidate)
        self.assigned[pair] = candidate
        return candidate


def _unescape_json_pointer(segment: str) -> str:
    """Unescape an RFC 6901 URI-fragment JSON Pointer token.

    Args:
        segment: Escaped JSON Pointer token.

    Returns:
        Unescaped string token.
    """
    return unquote(segment).replace("~1", "/").replace("~0", "~")


[docs] def find_asyncapi_plugin(app: "Litestar") -> Any | None: """Find an AsyncAPI plugin registered on the Litestar application. Queries Litestar's plugin registry for a registered ``AsyncAPIPlugin``, guarded by ``ASYNCAPI_INSTALLED``. Args: app: The Litestar application instance. Returns: The registered AsyncAPI plugin instance if found, otherwise None. """ if not ASYNCAPI_INSTALLED: return None plugins = getattr(app, "plugins", None) if plugins is not None and hasattr(plugins, "get"): with contextlib.suppress(KeyError, AttributeError): from litestar_asyncapi import AsyncAPIPlugin return plugins.get(AsyncAPIPlugin) with contextlib.suppress(KeyError, AttributeError): return plugins.get("AsyncAPIPlugin") return None
[docs] def asyncapi_docs_paths(app: "Litestar") -> tuple[str, ...]: """Return reserved AsyncAPI documentation route prefixes. When an AsyncAPI plugin is registered, returns a deduplicated tuple containing the default AsyncAPI documentation path ('/asyncapi') and any custom documentation path configured on the plugin. When no plugin is registered, returns an empty tuple. Args: app: The Litestar application instance. Returns: Tuple of reserved documentation path strings. """ plugin = find_asyncapi_plugin(app) if plugin is None: return () paths: list[str] = [ASYNCAPI_DOCS_DEFAULT_PATH] configured_path = getattr(getattr(getattr(plugin, "config", None), "docs", None), "path", None) if isinstance(configured_path, str) and configured_path.strip(): paths.append(configured_path.strip()) seen: set[str] = set() deduped: list[str] = [] for path in paths: if path not in seen: seen.add(path) deduped.append(path) return tuple(deduped)
def _derive_channel_bindings(ch: dict[str, Any], ops_for_channel: list[dict[str, Any]]) -> dict[str, Any]: """Derive default protocol bindings for a channel when omitted. When bindings are absent on the channel, derives WebSocket bindings if any operation has an action of 'receive' or the address starts with a WebSocket prefix. Derives HTTP SSE bindings if all operations are 'send' and any message specifies a 'text/event-stream' content type. Returns an empty bindings dict otherwise. Args: ch: The channel definition dictionary. ops_for_channel: Operations associated with this channel. Returns: A bindings dictionary containing protocol markers. """ address_raw = ch.get("address") address_str = str(address_raw) if address_raw is not None else "" has_receive_op = any(op.get("action") == "receive" for op in ops_for_channel) is_ws_address = address_str.startswith(("ws://", "wss://", "/ws")) has_send_only = bool(ops_for_channel) and all(op.get("action") == "send" for op in ops_for_channel) channel_messages: dict[str, Any] = ( cast("dict[str, Any]", ch.get("messages")) if isinstance(ch.get("messages"), dict) else {} ) has_sse_content_type = ( ch.get("defaultContentType") == "text/event-stream" or any( isinstance(m, dict) and cast("dict[str, Any]", m).get("contentType") == "text/event-stream" for m in channel_messages.values() ) or any( isinstance(m, dict) and cast("dict[str, Any]", m).get("contentType") == "text/event-stream" for op in ops_for_channel for m in (cast("list[Any]", op.get("messages")) if isinstance(op.get("messages"), list) else []) ) ) if has_receive_op or is_ws_address: return {"ws": {}} if has_send_only and has_sse_content_type: return {"http": {}} return {} def _matches_channel_ref(ch_ref: Any, old_key: str) -> bool: """Return True if a channel $ref points to old_key (raw or RFC 6901 escaped). Args: ch_ref: The $ref string from an operation channel object. old_key: The original channel key in the document. Returns: True when the reference targets old_key. """ if not isinstance(ch_ref, str) or not ch_ref.startswith("#/channels/"): return False raw_ref_key = ch_ref.removeprefix("#/channels/") return raw_ref_key == old_key or _unescape_json_pointer(raw_ref_key) == old_key def _prepare_channels( raw_channels: dict[str, Any], raw_operations: dict[str, Any], allocator: _ChannelKeyAllocator ) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: """Normalize and re-key channels with collision-safe names. Iterates each raw channel, associates matching operations, derives protocol bindings if absent, and allocates a deterministic channel key using _ChannelKeyAllocator. Args: raw_channels: Dictionary of raw channels from the source document. raw_operations: Dictionary of operations from the source document. allocator: Channel key allocator instance. Returns: A tuple of (prepared_channels_map, old_to_new_keys_map). """ old_to_new_keys: dict[str, str] = {} prepared_channels: dict[str, dict[str, Any]] = {} for old_key, channel_data in raw_channels.items(): ch: dict[str, Any] = cast("dict[str, Any]", channel_data).copy() if isinstance(channel_data, dict) else {} ops_for_channel: list[dict[str, Any]] = [] for raw_op in raw_operations.values(): if isinstance(raw_op, dict): typed_op = cast("dict[str, Any]", raw_op) ch_target = typed_op.get("channel") if isinstance(ch_target, dict) and _matches_channel_ref( cast("dict[str, Any]", ch_target).get("$ref"), old_key ): ops_for_channel.append(typed_op) if ch.get("bindings") is None: ch["bindings"] = _derive_channel_bindings(ch, ops_for_channel) if isinstance(ch.get("messages"), dict): normalized_messages: dict[str, Any] = {} for msg_key, raw_msg in cast("dict[str, Any]", ch["messages"]).items(): if isinstance(raw_msg, dict): msg_copy = cast("dict[str, Any]", raw_msg).copy() msg_copy.setdefault("payload", {}) normalized_messages[msg_key] = msg_copy else: normalized_messages[msg_key] = raw_msg ch["messages"] = normalized_messages bindings_dict: dict[str, Any] = ( cast("dict[str, Any]", ch.get("bindings")) if isinstance(ch.get("bindings"), dict) else {} ) if "ws" in bindings_dict: source = "websocket" elif "http" in bindings_dict: source = "sse" else: source = "channels" raw_address = ch.get("address") alloc_address = str(raw_address) if isinstance(raw_address, str) and raw_address else old_key new_key = allocator.allocate(alloc_address, source) old_to_new_keys[old_key] = new_key prepared_channels[new_key] = ch return prepared_channels, old_to_new_keys def _resolve_mapped_channel_key(raw_ref_key: str, old_to_new_keys: dict[str, str]) -> str | None: """Resolve a raw or RFC 6901 escaped channel key against old_to_new_keys. Args: raw_ref_key: Channel key segment from a $ref string. old_to_new_keys: Mapping of original channel keys to normalized keys. Returns: Normalized channel key if matched, otherwise None. """ if raw_ref_key in old_to_new_keys: return old_to_new_keys[raw_ref_key] unescaped = _unescape_json_pointer(raw_ref_key) return old_to_new_keys.get(unescaped) def _rewrite_operation_references(raw_operations: dict[str, Any], old_to_new_keys: dict[str, str]) -> dict[str, Any]: """Rewrite operation channel and message references to target normalized keys. Updates channel $ref and message $ref paths in each operation to use newly allocated channel keys. Args: raw_operations: Dictionary of raw operations. old_to_new_keys: Mapping of old channel keys to normalized channel keys. Returns: Dictionary of updated operations. """ new_operations: dict[str, Any] = {} for op_id, raw_op in raw_operations.items(): if not isinstance(raw_op, dict): new_operations[op_id] = raw_op continue op: dict[str, Any] = cast("dict[str, Any]", raw_op).copy() ch_ref_raw = op.get("channel") if isinstance(ch_ref_raw, dict): ch_ref_dict = cast("dict[str, Any]", ch_ref_raw).copy() ch_ref = ch_ref_dict.get("$ref") if isinstance(ch_ref, str) and ch_ref.startswith("#/channels/"): old_ref_key = ch_ref.removeprefix("#/channels/") mapped_ch_key = _resolve_mapped_channel_key(old_ref_key, old_to_new_keys) if mapped_ch_key is not None: ch_ref_dict["$ref"] = f"#/channels/{mapped_ch_key}" op["channel"] = ch_ref_dict raw_messages = op.get("messages") if isinstance(raw_messages, list): new_messages: list[Any] = [] for item in cast("list[Any]", raw_messages): if isinstance(item, dict): msg_dict = cast("dict[str, Any]", item).copy() msg_ref = msg_dict.get("$ref") if isinstance(msg_ref, str) and msg_ref.startswith("#/channels/"): remainder = msg_ref.removeprefix("#/channels/") parts = remainder.rsplit("/messages/", 1) ref_channel_key = parts[0] mapped_key = _resolve_mapped_channel_key(ref_channel_key, old_to_new_keys) if mapped_key is not None: if len(parts) > 1: msg_key = _unescape_json_pointer(parts[1]) msg_dict["$ref"] = f"#/channels/{mapped_key}/messages/{msg_key}" else: msg_dict["$ref"] = f"#/channels/{mapped_key}" new_messages.append(msg_dict) else: new_messages.append(item) op["messages"] = new_messages new_operations[op_id] = op return new_operations
[docs] def normalize_asyncapi_document(document: dict[str, Any]) -> dict[str, Any]: """Normalize an AsyncAPI document into a consistent internal shape. Guarantees that: 1. The asyncapi version string is present ('3.0.0' or '3.1.0'). 2. Channels are re-keyed into collision-safe litestar-vite format using _ChannelKeyAllocator with protocol-aware source selection. 3. Every channel has an explicit bindings dictionary with protocol markers. 4. Operation channel and message $ref pointers match the allocated channel keys. 5. Root document metadata, servers, and components are preserved intact. Args: document: Raw AsyncAPI document dictionary. Returns: A normalized AsyncAPI document dictionary. """ normalized: dict[str, Any] = dict(document) raw_version = document.get("asyncapi") normalized["asyncapi"] = str(raw_version) if raw_version else "3.0.0" raw_channels: dict[str, Any] = ( cast("dict[str, Any]", document.get("channels")) if isinstance(document.get("channels"), dict) else {} ) raw_operations: dict[str, Any] = ( cast("dict[str, Any]", document.get("operations")) if isinstance(document.get("operations"), dict) else {} ) allocator = _ChannelKeyAllocator() prepared_channels, old_to_new_keys = _prepare_channels(raw_channels, raw_operations, allocator) normalized["channels"] = prepared_channels normalized["operations"] = _rewrite_operation_references(raw_operations, old_to_new_keys) return normalized
[docs] def resolve_asyncapi_document( app: "Litestar", title: str | None = None, version: str | None = None ) -> dict[str, Any] | None: """Resolve and normalize the AsyncAPI document from a registered AsyncAPIPlugin. Queries the Litestar application for a registered ``AsyncAPIPlugin`` via ``find_asyncapi_plugin``. When present, retrieves the schema from the plugin via ``get_asyncapi_schema`` (or ``get_asyncapi_json`` if a non-dict is returned) and normalizes channel keys and operation references for frontend code generation. When no ``AsyncAPIPlugin`` is registered, returns ``None``. Args: app: The Litestar application instance. title: Optional title override for the AsyncAPI document. version: Optional version override for the AsyncAPI document. Returns: Normalized AsyncAPI document dictionary when ``AsyncAPIPlugin`` is registered, otherwise ``None``. """ plugin = find_asyncapi_plugin(app) if plugin is None: return None try: raw_schema: Any = plugin.get_asyncapi_schema(app) schema: dict[str, Any] | None = None if isinstance(raw_schema, dict): schema = cast("dict[str, Any]", raw_schema) elif hasattr(plugin, "get_asyncapi_json"): raw_json: Any = plugin.get_asyncapi_json(app) decoded: Any = msgspec.json.decode(raw_json) if isinstance(decoded, dict): schema = cast("dict[str, Any]", decoded) if schema is not None: normalized = normalize_asyncapi_document(schema) if title or version: info = normalized.setdefault("info", {}) if title: info["title"] = title if version: info["version"] = version return normalized except (AttributeError, TypeError, ValueError) as exc: msg = "AsyncAPIPlugin could not generate a valid AsyncAPI document" raise ValueError(msg) from exc msg = "AsyncAPIPlugin did not return an AsyncAPI document" raise ValueError(msg)