"""Unified asset export pipeline for deterministic code generation.
This module provides a single entry point for exporting all integration artifacts:
- openapi.json (OpenAPI schema with Inertia types registered)
- routes.json (route metadata)
- routes.ts (Ziggy-style typed routes)
- inertia-pages.json (Inertia page props metadata)
Both CLI and Plugin should call this function to guarantee byte-identical output.
"""
import contextlib
from dataclasses import dataclass, field
from functools import partial
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
from litestar import Litestar
from litestar_vite.config import TypeGenConfig, ViteConfig
[docs]
@dataclass
class ExportResult:
"""Result of the export operation."""
exported_files: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
"""Files that were written (content changed)."""
unchanged_files: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
"""Files that were skipped (content unchanged)."""
openapi_schema: "dict[str, Any] | None" = None
"""The OpenAPI schema dict (for downstream use)."""
asyncapi_schema: "dict[str, Any] | None" = None
"""The AsyncAPI 3.0 schema dict (for downstream use)."""
asyncapi_source: "str | None" = None
"""The authoritative source for the AsyncAPI schema ('litestar-asyncapi' or 'builtin')."""
def fmt_path(path: Path) -> str:
"""Format path for display, using relative path when possible.
Returns:
The result.
"""
try:
return str(path.relative_to(Path.cwd()))
except ValueError:
return str(path)
[docs]
def typegen_outputs_requested(types_config: "TypeGenConfig") -> bool:
"""Return whether the typegen config asks for any generated artifact."""
return any((
types_config.generate_sdk,
types_config.generate_zod,
types_config.generate_schemas,
types_config.generate_routes,
types_config.generate_page_props,
types_config.generate_channels,
))
[docs]
def app_has_realtime_surface(app: "Litestar") -> bool:
"""Return whether the Litestar application defines any realtime surface.
A realtime surface includes:
- Any plugin in ``app.plugins`` that provides AsyncAPI schemas.
- Any route in ``app.routes`` that is a ``litestar.routes.WebSocketRoute``.
- Any plugin in ``app.plugins`` that is a ``litestar.channels.ChannelsPlugin``.
- Any HTTP route handler whose return annotation represents a Server-Sent Event (SSE).
Args:
app: The Litestar application instance.
Returns:
True if any realtime route, plugin, or SSE handler is detected, otherwise False.
"""
from litestar_vite.codegen._asyncapi import find_asyncapi_plugin
if find_asyncapi_plugin(app) is not None:
return True
from litestar.channels import ChannelsPlugin
plugins = getattr(app, "plugins", None)
if plugins is not None and hasattr(plugins, "get"):
with contextlib.suppress(KeyError, AttributeError):
if plugins.get(ChannelsPlugin) is not None:
return True
with contextlib.suppress(KeyError, AttributeError):
if plugins.get("ChannelsPlugin") is not None:
return True
from litestar.routes import HTTPRoute, WebSocketRoute
for route in app.routes:
if isinstance(route, WebSocketRoute):
return True
from litestar_vite.codegen._asyncapi import _is_sse_type # pyright: ignore[reportPrivateUsage]
for route in app.routes:
if not isinstance(route, HTTPRoute):
continue
for handler in route.route_handlers:
return_field = getattr(handler, "parsed_return_field", None)
annotation = getattr(return_field, "annotation", None)
if annotation is None:
annotation = getattr(handler, "return_type", None)
if _is_sse_type(annotation):
return True
return False
def _resolve_serializer(
app: "Litestar", serializer: "Callable[[Any], bytes] | None" = None
) -> "Callable[[Any], bytes]":
"""Resolve a JSON serializer using application type encoders when none is provided.
Args:
app: The Litestar application instance.
serializer: An optional custom serializer function.
Returns:
A callable serializer for encoding JSON.
"""
if serializer is not None:
return serializer
encoders: Any
try:
encoders = app.type_encoders # pyright: ignore[reportUnknownMemberType]
except AttributeError:
encoders = None
from litestar.serialization import encode_json, get_serializer
return partial(encode_json, serializer=get_serializer(encoders if isinstance(encoders, dict) else None)) # pyright: ignore[reportUnknownArgumentType]
[docs]
def export_integration_assets(
app: "Litestar", config: "ViteConfig", *, serializer: "Callable[[Any], bytes] | None" = None
) -> ExportResult:
"""Export all integration artifacts with deterministic output.
This is the single source of truth for code generation. Both CLI commands
and Plugin startup should call this function to ensure byte-identical output.
AsyncAPI export is independent of OpenAPI availability when channels are
enabled and a realtime surface is present on the application (WebSocket routes,
ChannelsPlugin, or SSE handlers). The configuration flag generate_channels=True
is an opt-in ceiling rather than a mandate, so a REST-only application produces
no AsyncAPI artifacts even when generate_channels is true. OpenAPI schema, route
metadata, route definitions, and Inertia page prop artifacts require an active
OpenAPI configuration.
The export order is critical:
1. Register Inertia page prop types in OpenAPI schema (mutates schema_dict)
2. Export openapi.json (now includes session prop types)
3. Export routes.json (uses schema for component refs)
4. Export routes.ts (if enabled)
5. Export inertia-pages.json (if enabled)
6. Export asyncapi.json (if enabled)
Args:
app: The Litestar application instance.
config: The ViteConfig instance.
serializer: Optional custom serializer for OpenAPI schema encoding.
Returns:
ExportResult with lists of exported and unchanged files.
"""
from litestar._openapi.plugin import OpenAPIPlugin
from litestar_vite.codegen._inertia import generate_inertia_pages_json
from litestar_vite.codegen._routes import extract_route_metadata
from litestar_vite.config import InertiaConfig, InertiaTypeGenConfig, TypeGenConfig
result = ExportResult()
if not isinstance(config.types, TypeGenConfig):
return result
types_config = config.types
if not typegen_outputs_requested(types_config):
return result
from litestar.exceptions import ImproperlyConfiguredException
plugins = getattr(app, "plugins", ())
openapi_plugin = next((p for p in plugins if isinstance(p, OpenAPIPlugin)), None)
has_openapi = False
if openapi_plugin is not None:
try:
_ = openapi_plugin.openapi_config
has_openapi = True
except ImproperlyConfiguredException:
has_openapi = False
if not has_openapi:
if types_config.generate_channels and app_has_realtime_surface(app):
export_asyncapi(
app=app, types_config=types_config, serializer=_resolve_serializer(app, serializer), result=result
)
return result
serializer = _resolve_serializer(app, serializer)
schema_dict = app.openapi_schema.to_schema()
inertia_pages_data: dict[str, Any] | None = None
if isinstance(config.inertia, InertiaConfig) and types_config.generate_page_props:
inertia_type_gen = config.inertia.type_gen or InertiaTypeGenConfig()
inertia_pages_data = generate_inertia_pages_json(
app,
openapi_schema=schema_dict,
include_default_auth=inertia_type_gen.include_default_auth,
include_default_flash=inertia_type_gen.include_default_flash,
inertia_config=config.inertia,
types_config=types_config,
)
result.openapi_schema = schema_dict
export_openapi(schema_dict=schema_dict, types_config=types_config, serializer=serializer, result=result)
routes_metadata = extract_route_metadata(app, openapi_schema=schema_dict)
export_routes_json(
app=app, types_config=types_config, openapi_schema=schema_dict, routes_metadata=routes_metadata, result=result
)
if types_config.generate_routes:
export_routes_ts(
app=app,
types_config=types_config,
openapi_schema=schema_dict,
routes_metadata=routes_metadata,
result=result,
)
if (
isinstance(config.inertia, InertiaConfig)
and types_config.generate_page_props
and types_config.page_props_path
and inertia_pages_data is not None
):
export_inertia_pages(pages_data=inertia_pages_data, types_config=types_config, result=result)
if types_config.generate_channels and app_has_realtime_surface(app):
export_asyncapi(app=app, types_config=types_config, serializer=serializer, result=result)
return result
def export_openapi(
*,
schema_dict: "dict[str, Any]",
types_config: "TypeGenConfig",
serializer: "Callable[[Any], bytes]",
result: ExportResult,
) -> None:
"""Export OpenAPI schema to file."""
from litestar_vite.codegen._utils import encode_deterministic_json, write_if_changed
openapi_path = types_config.openapi_path
if openapi_path is None:
openapi_path = types_config.output / "openapi.json"
schema_content = encode_deterministic_json(schema_dict, serializer=serializer)
if write_if_changed(openapi_path, schema_content):
result.exported_files.append(f"openapi: {fmt_path(openapi_path)}")
else:
result.unchanged_files.append("openapi.json")
def export_routes_json(
*,
app: "Litestar",
types_config: "TypeGenConfig",
openapi_schema: "dict[str, Any]",
routes_metadata: "list[Any]",
result: ExportResult,
) -> None:
"""Export routes metadata to JSON file."""
from litestar_vite.codegen._routes import generate_routes_json
from litestar_vite.codegen._utils import encode_deterministic_json, write_if_changed
try:
litestar_version = version("litestar")
except PackageNotFoundError:
litestar_version = "unknown"
routes_path = types_config.routes_path
if routes_path is None:
routes_path = types_config.output / "routes.json"
routes_data = generate_routes_json(
app, include_components=True, openapi_schema=openapi_schema, routes_metadata=routes_metadata
)
routes_data["litestar_version"] = litestar_version
routes_content = encode_deterministic_json(routes_data)
if write_if_changed(routes_path, routes_content):
result.exported_files.append(fmt_path(routes_path))
else:
result.unchanged_files.append("routes.json")
def export_routes_ts(
*,
app: "Litestar",
types_config: "TypeGenConfig",
openapi_schema: "dict[str, Any]",
routes_metadata: "list[Any]",
result: ExportResult,
) -> None:
"""Export typed routes TypeScript file."""
from litestar_vite.codegen._routes import generate_routes_ts
from litestar_vite.codegen._utils import write_if_changed
routes_ts_path = types_config.routes_ts_path
if routes_ts_path is None:
routes_ts_path = types_config.output / "routes.ts"
routes_ts_content = generate_routes_ts(
app, openapi_schema=openapi_schema, global_route=types_config.global_route, routes_metadata=routes_metadata
)
if write_if_changed(routes_ts_path, routes_ts_content):
result.exported_files.append(fmt_path(routes_ts_path))
else:
result.unchanged_files.append("routes.ts")
def export_inertia_pages(*, pages_data: "dict[str, Any]", types_config: "TypeGenConfig", result: ExportResult) -> None:
"""Export Inertia pages metadata to JSON file."""
from litestar_vite.codegen._utils import encode_deterministic_json, write_if_changed
page_props_path = types_config.page_props_path
if page_props_path is None:
return
pages_content = encode_deterministic_json(pages_data)
if write_if_changed(page_props_path, pages_content):
result.exported_files.append(fmt_path(page_props_path))
else:
result.unchanged_files.append("inertia-pages.json")
[docs]
def export_asyncapi(
*,
app: "Litestar",
types_config: "TypeGenConfig",
serializer: "Callable[[Any], bytes] | None" = None,
result: ExportResult,
) -> None:
"""Export AsyncAPI 3.0 schema to file.
Args:
app: The Litestar application instance.
types_config: The type generation configuration.
serializer: Optional custom serializer for JSON encoding.
result: ExportResult accumulator for exported or unchanged files.
"""
from litestar_vite.codegen._asyncapi import resolve_asyncapi_document
from litestar_vite.codegen._utils import encode_deterministic_json, write_if_changed
asyncapi_path = types_config.asyncapi_path
if asyncapi_path is None:
asyncapi_path = types_config.output / "asyncapi.json"
schema_dict, source = resolve_asyncapi_document(app)
result.asyncapi_schema = schema_dict
result.asyncapi_source = source
schema_content = encode_deterministic_json(schema_dict, serializer=serializer)
if write_if_changed(asyncapi_path, schema_content):
result.exported_files.append(f"asyncapi: {fmt_path(asyncapi_path)}")
else:
result.unchanged_files.append("asyncapi.json")