Source code for litestar_vite._codegen.inertia

"""Inertia page-props metadata extraction and export."""

import datetime
import re
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast

from litestar._openapi.datastructures import _get_normalized_schema_key  # pyright: ignore[reportPrivateUsage]
from litestar.handlers import HTTPRouteHandler
from litestar.openapi.spec import Reference, Schema
from litestar.response.base import ASGIResponse
from litestar.routes import HTTPRoute
from litestar.types.builtin_types import NoneType
from litestar.typing import FieldDefinition

from litestar_vite._codegen.openapi import (
    OpenAPISupport,
    build_schema_name_map,
    merge_generated_components_into_openapi,
    openapi_components_schemas,
    resolve_page_props_field_definition,
    schema_name_from_ref,
)
from litestar_vite._codegen.ts import collect_ref_names, normalize_path, python_type_to_typescript, ts_type_from_openapi

if TYPE_CHECKING:
    from litestar import Litestar

    from litestar_vite.config import InertiaConfig, TypeGenConfig

# Compiled regex for splitting TypeScript type strings on union/intersection operators
_TYPE_OPERATOR_RE = re.compile(r"(\s*[|&]\s*)")


def _str_list_factory() -> list[str]:
    """Return an empty ``list[str]`` (typed for pyright).

    Returns:
        An empty list.
    """
    return []


def _normalize_type_name(type_name: str, openapi_schemas: set[str]) -> str:
    """Strip module prefix from mangled type names.

    Always converts 'app_lib_schema_NoProps' -> 'NoProps' because:
    1. If 'NoProps' exists in OpenAPI, it will be imported correctly
    2. If 'NoProps' doesn't exist, the error message is clearer for users
       (they can add it to OpenAPI or configure type_import_paths)

    The mangled name 'app_lib_schema_NoProps' will NEVER work - it doesn't
    exist anywhere. The short name is always preferable.

    Args:
        type_name: The potentially mangled type name.
        openapi_schemas: Set of available OpenAPI schema names.

    Returns:
        The normalized (unmangled) type name.
    """
    if type_name in openapi_schemas:
        return type_name

    # Check if this looks like a mangled module path (contains underscores)
    if "_" not in type_name:
        return type_name

    # Try progressively shorter suffixes to find the class name
    parts = type_name.split("_")
    for i in range(len(parts)):
        short_name = "_".join(parts[i:])
        # Prefer OpenAPI match, but if we get to the last part, use it anyway
        if short_name in openapi_schemas:
            return short_name

    # Use the last part as the class name (e.g., 'NoProps' from 'app_lib_schema_NoProps')
    # This is always better than the mangled name for error messages
    return parts[-1] if parts else type_name


def _normalize_type_string(type_string: str, openapi_schemas: set[str]) -> str:
    """Normalize all type names within a TypeScript type string.

    Handles union types like 'any | app_lib_schema_NoProps' by parsing the
    string and normalizing each type name individually.

    Args:
        type_string: A TypeScript type string (may contain unions, intersections).
        openapi_schemas: Set of available OpenAPI schema names.

    Returns:
        The type string with all type names normalized.
    """
    # Primitives and special types that should not be normalized
    skip_types = {"any", "unknown", "null", "undefined", "void", "never", "string", "number", "boolean", "object"}

    # Split on | and & while preserving whitespace
    tokens = _TYPE_OPERATOR_RE.split(type_string)
    result_parts: list[str] = []

    for token in tokens:
        stripped = token.strip()
        # Keep operators and whitespace as-is
        if stripped in {"|", "&", ""} or stripped in skip_types or stripped == "{}":
            result_parts.append(token)
        # Normalize type names
        else:
            normalized = _normalize_type_name(stripped, openapi_schemas)
            # Preserve original whitespace around the type
            prefix = token[: len(token) - len(token.lstrip())]
            suffix = token[len(token.rstrip()) :]
            result_parts.append(prefix + normalized + suffix)

    return "".join(result_parts)


[docs] @dataclass class InertiaPageMetadata: """Metadata for a single Inertia page component.""" component: str route_path: str props_type: str | None = None schema_ref: str | None = None handler_name: str | None = None ts_type: str | None = None custom_types: list[str] = field(default_factory=_str_list_factory)
def _get_return_type_name(handler: HTTPRouteHandler) -> "str | None": field_definition = handler.parsed_fn_signature.return_type excluded_types: tuple[type[Any], ...] = (NoneType, ASGIResponse) if field_definition.is_subclass_of(excluded_types): return None fn = handler.fn with suppress(AttributeError): return_annotation = fn.__annotations__.get("return") if isinstance(return_annotation, str) and return_annotation: return return_annotation raw = field_definition.raw if isinstance(raw, str): return raw if isinstance(raw, type): return raw.__name__ origin: Any = None with suppress(AttributeError): origin = field_definition.origin if isinstance(origin, type): return origin.__name__ return str(raw) def _get_openapi_schema_ref( handler: HTTPRouteHandler, openapi_schema: dict[str, Any] | None, route_path: str, method: str = "GET" ) -> "str | None": if not openapi_schema: return None paths = openapi_schema.get("paths", {}) path_item = paths.get(route_path, {}) operation = path_item.get(method.lower(), {}) responses = operation.get("responses", {}) success_response = responses.get("200", responses.get("2XX", {})) content = success_response.get("content", {}) json_content = content.get("application/json", {}) schema = json_content.get("schema", {}) ref = schema.get("$ref") return cast("str | None", ref) if ref else None def _extract_inertia_component(handler: HTTPRouteHandler) -> str | None: opt = handler.opt or {} component = opt.get("component") or opt.get("page") return component if isinstance(component, str) and component else None def _infer_inertia_props_type( component: str, handler: HTTPRouteHandler, schema_creator: Any, page_schema_keys: dict[str, tuple[str, ...]], page_schema_dicts: dict[str, dict[str, Any]], *, fallback_type: str, ) -> str | None: if schema_creator is not None: field_def, schema_result = resolve_page_props_field_definition(handler, schema_creator) if field_def is not None and isinstance(schema_result, Reference): page_schema_keys[component] = _get_normalized_schema_key(field_def) return None if isinstance(schema_result, Schema): schema_dict = schema_result.to_schema() page_schema_dicts[component] = schema_dict return ts_type_from_openapi(schema_dict) return None raw_type = _get_return_type_name(handler) if not raw_type: return None props_type, _ = python_type_to_typescript(raw_type, fallback=fallback_type) return props_type def _finalize_inertia_pages( pages: list[InertiaPageMetadata], *, openapi_support: OpenAPISupport, page_schema_keys: dict[str, tuple[str, ...]], page_schema_dicts: dict[str, dict[str, Any]], ) -> None: context = openapi_support.context if context is None: return generated_components = context.schema_registry.generate_components_schemas() name_map = build_schema_name_map(context.schema_registry) openapi_components = openapi_components_schemas(openapi_support.openapi_schema) # Build set of available OpenAPI schema names for type normalization openapi_schema_names: set[str] = set(openapi_components.keys()) openapi_schema_names.update(generated_components.keys()) if openapi_support.openapi_schema is not None: merge_generated_components_into_openapi(openapi_support.openapi_schema, generated_components) for page in pages: schema_key = page_schema_keys.get(page.component) schema_name: str | None = None if page.schema_ref: schema_name = schema_name_from_ref(page.schema_ref) elif schema_key: schema_name = name_map.get(schema_key) if schema_name: # Normalize mangled type names (e.g., 'app_lib_schema_NoProps' -> 'NoProps') normalized_name = _normalize_type_name(schema_name, openapi_schema_names) page.ts_type = normalized_name page.props_type = normalized_name elif page.props_type: # Normalize type names in union/intersection type strings # (e.g., 'any | app_lib_schema_NoProps' -> 'any | NoProps') page.props_type = _normalize_type_string(page.props_type, openapi_schema_names) custom_types: set[str] = set() if page.ts_type: custom_types.add(page.ts_type) if page.schema_ref: openapi_schema_dict = openapi_components.get(page.ts_type or "") if isinstance(openapi_schema_dict, dict): custom_types.update(collect_ref_names(openapi_schema_dict)) else: page_schema_dict = page_schema_dicts.get(page.component) if isinstance(page_schema_dict, dict): custom_types.update(collect_ref_names(page_schema_dict)) elif schema_key: registered = context.schema_registry._schema_key_map.get( # pyright: ignore[reportPrivateUsage] schema_key ) if registered: custom_types.update(collect_ref_names(registered.schema.to_schema())) # Normalize all custom type names page.custom_types = sorted(_normalize_type_name(t, openapi_schema_names) for t in custom_types) def extract_inertia_pages( app: "Litestar", *, openapi_schema: dict[str, Any] | None = None, fallback_type: "str" = "unknown" ) -> list[InertiaPageMetadata]: pages: list[InertiaPageMetadata] = [] openapi_support = OpenAPISupport.from_app(app, openapi_schema) page_schema_keys: dict[str, tuple[str, ...]] = {} page_schema_dicts: dict[str, dict[str, Any]] = {} for http_route, route_handler in _iter_route_handlers(app): component = _extract_inertia_component(route_handler) if not component: continue normalized_path = normalize_path(str(http_route.path)) handler_name = route_handler.handler_name or route_handler.name props_type = _infer_inertia_props_type( component, route_handler, openapi_support.schema_creator, page_schema_keys, page_schema_dicts, fallback_type=fallback_type, ) method = next(iter(route_handler.http_methods), "GET") if route_handler.http_methods else "GET" schema_ref = _get_openapi_schema_ref(route_handler, openapi_schema, normalized_path, method=str(method)) pages.append( InertiaPageMetadata( component=component, route_path=normalized_path, props_type=props_type, schema_ref=schema_ref, handler_name=handler_name, ) ) if openapi_support.enabled: _finalize_inertia_pages( pages, openapi_support=openapi_support, page_schema_keys=page_schema_keys, page_schema_dicts=page_schema_dicts, ) return pages def _iter_route_handlers(app: "Litestar") -> "list[tuple[HTTPRoute, HTTPRouteHandler]]": """Iterate over HTTP route handlers in an app. Returns: A list of (http_route, route_handler) tuples. """ handlers: list[tuple[HTTPRoute, HTTPRouteHandler]] = [] for route in app.routes: if isinstance(route, HTTPRoute): handlers.extend((route, route_handler) for route_handler in route.route_handlers) return handlers def _fallback_ts_type(types_config: "TypeGenConfig | None") -> str: fallback_type = types_config.fallback_type if types_config is not None else "unknown" return "any" if fallback_type == "any" else "unknown" def _ts_type_from_value(value: Any, *, fallback_ts_type: str) -> str: ts_type = fallback_ts_type if value is None: ts_type = "null" elif isinstance(value, bool): ts_type = "boolean" elif isinstance(value, str): ts_type = "string" elif isinstance(value, (int, float)): ts_type = "number" elif isinstance(value, (bytes, bytearray, Path)): ts_type = "string" elif isinstance(value, (list, tuple, set, frozenset)): ts_type = f"{fallback_ts_type}[]" elif isinstance(value, dict): ts_type = f"Record<string, {fallback_ts_type}>" return ts_type def _should_register_value_schema(value: Any) -> bool: if value is None: return False return not isinstance(value, (bool, str, int, float, bytes, bytearray, Path, list, tuple, set, frozenset, dict)) def _process_session_props( session_props: "set[str] | dict[str, type]", shared_props: dict[str, dict[str, Any]], shared_schema_keys: dict[str, tuple[str, ...]], openapi_support: OpenAPISupport, fallback_ts_type: str, ) -> None: """Process session props and add them to shared_props. Handles both set[str] (legacy) and dict[str, type] (new typed) formats. """ if isinstance(session_props, dict): # New behavior: dict maps prop names to Python types for key, prop_type_class in session_props.items(): if not key: continue # Register the type with OpenAPI if possible if openapi_support.enabled and openapi_support.schema_creator: try: field_def = FieldDefinition.from_annotation(prop_type_class) schema_result = openapi_support.schema_creator.for_field_definition(field_def) if isinstance(schema_result, Reference): shared_schema_keys[key] = _get_normalized_schema_key(field_def) type_name = prop_type_class.__name__ if hasattr(prop_type_class, "__name__") else fallback_ts_type shared_props.setdefault(key, {"type": type_name, "optional": True}) except (AttributeError, TypeError, ValueError): # pragma: no cover - defensive shared_props.setdefault(key, {"type": fallback_ts_type, "optional": True}) else: type_name = prop_type_class.__name__ if hasattr(prop_type_class, "__name__") else fallback_ts_type shared_props.setdefault(key, {"type": type_name, "optional": True}) else: # Legacy behavior: set of prop names (types are unknown) for key in session_props: if not key: continue shared_props.setdefault(key, {"type": fallback_ts_type, "optional": True}) def _build_inertia_shared_props( app: "Litestar", *, openapi_schema: dict[str, Any] | None, include_default_auth: bool, include_default_flash: bool, inertia_config: "InertiaConfig | None", types_config: "TypeGenConfig | None", ) -> dict[str, dict[str, Any]]: """Build shared props metadata (built-ins + configured props). Returns: Mapping of shared prop name to metadata payload. """ fallback_ts_type = _fallback_ts_type(types_config) shared_props: dict[str, dict[str, Any]] = { "errors": {"type": "Record<string, string[]>", "optional": True}, "csrf_token": {"type": "string", "optional": True}, } if include_default_auth or include_default_flash: shared_props["auth"] = {"type": "AuthData", "optional": True} shared_props["flash"] = {"type": "FlashMessages", "optional": True} if inertia_config is None: return shared_props openapi_support = OpenAPISupport.from_app(app, openapi_schema) shared_schema_keys: dict[str, tuple[str, ...]] = {} for key, value in inertia_config.extra_static_page_props.items(): if not key: continue shared_props[key] = {"type": _ts_type_from_value(value, fallback_ts_type=fallback_ts_type), "optional": True} if openapi_support.enabled and isinstance(openapi_schema, dict) and _should_register_value_schema(value): try: field_def = FieldDefinition.from_annotation(value.__class__) schema_result = openapi_support.schema_creator.for_field_definition(field_def) # type: ignore[union-attr] if isinstance(schema_result, Reference): shared_schema_keys[key] = _get_normalized_schema_key(field_def) except (AttributeError, TypeError, ValueError): # pragma: no cover - defensive pass # Handle session props - can be set[str] or dict[str, type] _process_session_props( inertia_config.extra_session_page_props, shared_props, shared_schema_keys, openapi_support, fallback_ts_type ) if not ( openapi_support.context and openapi_support.schema_creator and isinstance(openapi_schema, dict) and shared_schema_keys ): return shared_props generated_components = openapi_support.context.schema_registry.generate_components_schemas() name_map = build_schema_name_map(openapi_support.context.schema_registry) merge_generated_components_into_openapi(openapi_schema, generated_components) for prop_name, schema_key in shared_schema_keys.items(): type_name = name_map.get(schema_key) if type_name: shared_props[prop_name]["type"] = type_name return shared_props
[docs] def generate_inertia_pages_json( app: "Litestar", *, openapi_schema: dict[str, Any] | None = None, include_default_auth: bool = True, include_default_flash: bool = True, inertia_config: "InertiaConfig | None" = None, types_config: "TypeGenConfig | None" = None, ) -> dict[str, Any]: """Generate Inertia pages metadata JSON. The output is deterministic: all dict keys are sorted alphabetically to produce byte-identical output for the same input data. Returns: An Inertia pages metadata payload as a dictionary with sorted keys. """ pages_metadata = extract_inertia_pages( app, openapi_schema=openapi_schema, fallback_type=types_config.fallback_type if types_config is not None else "unknown", ) pages_dict: dict[str, dict[str, Any]] = {} for page in pages_metadata: page_data: dict[str, Any] = {"route": page.route_path} if page.props_type: page_data["propsType"] = page.props_type if page.ts_type: page_data["tsType"] = page.ts_type if page.custom_types: page_data["customTypes"] = page.custom_types if page.schema_ref: page_data["schemaRef"] = page.schema_ref if page.handler_name: page_data["handler"] = page.handler_name pages_dict[page.component] = page_data shared_props = _build_inertia_shared_props( app, openapi_schema=openapi_schema, include_default_auth=include_default_auth, include_default_flash=include_default_flash, inertia_config=inertia_config, types_config=types_config, ) # Sort all dict keys for deterministic output # Pages sorted by component name, shared props sorted by prop name sorted_pages = dict(sorted(pages_dict.items())) sorted_shared_props = dict(sorted(shared_props.items())) root: dict[str, Any] = { "fallbackType": types_config.fallback_type if types_config is not None else None, "generatedAt": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), "pages": sorted_pages, "sharedProps": sorted_shared_props, "typeGenConfig": {"includeDefaultAuth": include_default_auth, "includeDefaultFlash": include_default_flash}, "typeImportPaths": types_config.type_import_paths if types_config is not None else None, } # Remove None values for cleaner output return {k: v for k, v in root.items() if v is not None}