feat: add wasm to open api docs (#4074)

This commit is contained in:
Vlad Stan 2026-07-21 12:35:59 +03:00 committed by GitHub
parent 9177e2262a
commit c5b5651d8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1006 additions and 63 deletions

View file

@ -48,7 +48,10 @@ from lnbits.core.tasks import (
process_next_audit_entry,
refresh_extension_cache,
)
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
from lnbits.core.wasm_ext.routes.register import (
register_wasm_extension,
unregister_wasm_extension,
)
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
from lnbits.core.wasm_ext.wasm.loader import (
is_wasm_extension_id,
@ -177,6 +180,7 @@ def create_app() -> FastAPI:
# Allow registering new extensions routes without direct access to the `app` object
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app)
core_app_extra.unregister_wasm_ext_routes = unregister_wasm_ext_routes(app)
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
# register static files
@ -430,6 +434,13 @@ def register_new_wasm_ext_routes(app: FastAPI) -> Callable:
return register_new_wasm_ext_routes_fn
def unregister_wasm_ext_routes(app: FastAPI) -> Callable:
def unregister_wasm_ext_routes_fn(ext_id: str):
unregister_wasm_extension(app, ext_id)
return unregister_wasm_ext_routes_fn
def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_new_ratelimiter_fn():
limiter = Limiter(

View file

@ -14,6 +14,7 @@ def _do_nothing(*_):
class CoreAppExtra:
register_new_ext_routes: Callable = _do_nothing
register_new_wasm_ext_routes: Callable = _do_nothing
unregister_wasm_ext_routes: Callable = _do_nothing
register_new_ratelimiter: Callable
def __init__(self) -> None:
@ -38,6 +39,9 @@ class WasmExtensionRegistry:
def get(self, ext_id: str) -> Any | None:
return self._extensions.get(ext_id)
def unregister(self, ext_id: str) -> None:
self._extensions.pop(ext_id, None)
def list(self) -> list[Any]:
return list(self._extensions.values())

View file

@ -639,6 +639,7 @@ def _now() -> datetime:
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
core_app_extra.unregister_wasm_ext_routes(ext_id)
settings.deactivate_extension_paths(ext_id)

View file

@ -112,7 +112,7 @@ class WasmExtensionWebsocketHub:
max_messages_per_second=max_messages_per_second,
)
for conn in self.get_connections(extension_id, item_id):
await conn.websocket.send_text(data)
await self._send_to_connection(conn, data)
def _check_publish_rate(
self,
@ -160,7 +160,17 @@ class WasmExtensionWebsocketHub:
for active_conn in self.get_connections(conn.extension_id, conn.item_id):
if active_conn.websocket == conn.websocket:
continue
await active_conn.websocket.send_text(data)
await self._send_to_connection(active_conn, data)
async def _send_to_connection(
self,
conn: WasmExtensionWebsocketConnection,
data: str,
) -> None:
try:
await conn.websocket.send_text(data)
except (RuntimeError, WebSocketDisconnect):
self.disconnect(conn)
wasm_extension_websocket_hub = WasmExtensionWebsocketHub()

View file

@ -1,3 +1,3 @@
from .register import register_wasm_extension
from .register import register_wasm_extension, unregister_wasm_extension
__all__ = ["register_wasm_extension"]
__all__ = ["register_wasm_extension", "unregister_wasm_extension"]

View file

@ -16,6 +16,9 @@ from lnbits.settings import settings
from ..wasm.config import WasmAPIRouteConfig
from ..wasm.invoke import invoke_wasm_extension_export
from ..wasm.loader import WasmExtension
from .open_api import wasm_extension_api_openapi_metadata, wasm_extension_api_tag
_WASM_EXTENSION_API_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"}
class WasmRequestBodyTooLargeError(ValueError):
@ -28,24 +31,54 @@ class WasmRoutePayload:
request_bytes: int | None
@dataclass(frozen=True)
class WasmAPIRouteRegistration:
route_config: WasmAPIRouteConfig
method: str
route_path: str
export_name: str
path_params: dict[str, str]
auth: str
route_name: str
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
for route_config in extension.config.api_routes:
_add_wasm_extension_api_route(app, extension, route_config)
route_registrations = [
_wasm_extension_api_route_registration(extension, route_config)
for route_config in extension.config.api_routes
]
openapi_schema_changed = _remove_wasm_extension_api_routes(app, extension.id)
for route_registration in route_registrations:
if _add_wasm_extension_api_route(app, extension, route_registration):
openapi_schema_changed = True
if openapi_schema_changed:
app.openapi_schema = None
def unregister_wasm_extension_api_routes(app: FastAPI, ext_id: str) -> bool:
openapi_schema_changed = _remove_wasm_extension_api_routes(app, ext_id)
if openapi_schema_changed:
app.openapi_schema = None
return openapi_schema_changed
def _add_wasm_extension_api_route(
app: FastAPI,
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
) -> None:
method = _wasm_extension_api_method(extension, route_config.method)
route_path = _wasm_extension_api_path(extension, route_config.path)
export_name = _wasm_extension_api_export(extension, route_config.export)
path_params = route_config.path_params
auth = _wasm_extension_route_auth(extension, route_config.auth)
route_registration: WasmAPIRouteRegistration,
) -> bool:
method = route_registration.method
route_path = route_registration.route_path
export_name = route_registration.export_name
path_params = route_registration.path_params
auth = route_registration.auth
route_name = route_registration.route_name
route_config = route_registration.route_config
openapi = wasm_extension_api_openapi_metadata(extension, route_config, method)
if _has_route(app, route_path, method):
return
if not _prepare_wasm_extension_api_route(app, route_path, method, route_name):
return False
async def invoke_wasm_api_request(
request: Request,
@ -102,9 +135,15 @@ def _add_wasm_extension_api_route(
else invoke_private_wasm_extension_export
),
methods=[method],
name=f"{extension.id}:{method}:{route_path}",
include_in_schema=False,
name=route_name,
tags=[wasm_extension_api_tag(extension)],
summary=openapi.summary,
description=openapi.description,
operation_id=openapi.operation_id,
openapi_extra=openapi.openapi_extra,
include_in_schema=True,
)
return True
async def _read_api_payload(
@ -247,7 +286,7 @@ def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
if not isinstance(method, str):
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
method = method.upper()
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
if method not in _WASM_EXTENSION_API_METHODS:
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
return method
@ -276,6 +315,77 @@ def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
return False
def _wasm_extension_api_route_registration(
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
) -> WasmAPIRouteRegistration:
method = _wasm_extension_api_method(extension, route_config.method)
route_path = _wasm_extension_api_path(extension, route_config.path)
return WasmAPIRouteRegistration(
route_config=route_config,
method=method,
route_path=route_path,
export_name=_wasm_extension_api_export(extension, route_config.export),
path_params=route_config.path_params,
auth=_wasm_extension_route_auth(extension, route_config.auth),
route_name=_wasm_extension_api_route_name(extension.id, method, route_path),
)
def _remove_wasm_extension_api_routes(app: FastAPI, ext_id: str) -> bool:
removed = False
for route in list(app.router.routes):
if not _is_wasm_extension_api_route(route, ext_id):
continue
app.router.routes.remove(route)
removed = True
return removed
def _is_wasm_extension_api_route(route: Any, ext_id: str) -> bool:
route_name = getattr(route, "name", None)
if not isinstance(route_name, str) or not route_name.startswith(f"{ext_id}:"):
return False
route_name_parts = route_name.split(":", 2)
if len(route_name_parts) != 3:
return False
_, route_method, named_route_path = route_name_parts
if route_method not in _WASM_EXTENSION_API_METHODS:
return False
route_path = getattr(route, "path", None)
if not isinstance(route_path, str):
return False
route_prefix = f"/api/v1/ext/{ext_id}"
if route_path != route_prefix and not route_path.startswith(f"{route_prefix}/"):
return False
return named_route_path == route_path
def _prepare_wasm_extension_api_route(
app: FastAPI,
route_path: str,
method: str,
route_name: str,
) -> bool:
for route in list(app.router.routes):
if getattr(route, "path", None) != route_path:
continue
methods = getattr(route, "methods", set()) or set()
if method not in methods:
continue
if getattr(route, "name", None) != route_name:
return False
app.router.routes.remove(route)
return True
return True
def _wasm_extension_api_route_name(ext_id: str, method: str, route_path: str) -> str:
return f"{ext_id}:{method}:{route_path}"
def _snake_to_camel(value: str) -> str:
head, *tail = value.split("_")
return head + "".join(part.capitalize() for part in tail)

View file

@ -0,0 +1,356 @@
from __future__ import annotations
import json
import re
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from loguru import logger
from ..wasm.config import WasmAPIRouteConfig
from ..wasm.loader import WasmExtension
@dataclass(frozen=True)
class WasmOpenAPIMetadata:
summary: str
description: str | None
operation_id: str
openapi_extra: dict[str, Any] | None
_MISSING_OPENAPI_EXAMPLE = object()
def wasm_extension_api_tag(extension: WasmExtension) -> str:
return extension.name.strip() or extension.id
def wasm_extension_api_openapi_metadata(
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
method: str,
) -> WasmOpenAPIMetadata:
operation = _load_wasm_extension_openapi_operation(
extension,
route_config,
)
summary = _openapi_string(operation.pop("summary", None)) or (
f"{method} {route_config.path}"
)
description = _openapi_string(operation.pop("description", None))
operation_id = (
_openapi_string(operation.pop("operationId", None))
or _openapi_string(operation.pop("operation_id", None))
or _wasm_extension_default_operation_id(extension, route_config, method)
)
operation.pop("tags", None)
_add_wasm_openapi_success_examples(operation)
return WasmOpenAPIMetadata(
summary=summary,
description=description,
operation_id=operation_id,
openapi_extra=operation or None,
)
def _load_wasm_extension_openapi_operation(
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
) -> dict[str, Any]:
try:
openapi_refs = _wasm_extension_openapi_refs(extension, route_config)
except Exception as exc:
logger.warning(
f"Ignoring OpenAPI metadata for WASM extension '{extension.id}' "
f"route '{route_config.path}': {exc}"
)
return {}
if not openapi_refs:
return {}
errors: list[Exception] = []
for openapi_ref in openapi_refs:
try:
document_path, pointer = _wasm_openapi_ref_parts(openapi_ref)
document = _load_wasm_openapi_document(extension, document_path)
operation = _resolve_json_pointer(document, pointer)
if not isinstance(operation, dict):
raise TypeError("OpenAPI route fragment must resolve to an object.")
return _inline_wasm_openapi_refs(deepcopy(operation), document)
except Exception as exc:
errors.append(exc)
logger.warning(
f"Ignoring OpenAPI metadata for WASM extension '{extension.id}' "
f"route '{route_config.path}': {errors[-1]}"
)
return {}
def _wasm_extension_openapi_refs(
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
) -> list[str]:
if route_config.openapi:
return [
_wasm_openapi_resolved_ref(
extension.config.openapi,
route_config.openapi,
)
]
document_path = extension.config.openapi
if not document_path:
return []
document_path = _wasm_openapi_document_path(document_path)
route_keys = [route_config.export, _wasm_openapi_route_key(route_config.export)]
return [
f"{document_path}#/routes/{_json_pointer_token(route_key)}"
for route_key in dict.fromkeys(route_keys)
]
def _wasm_openapi_resolved_ref(
base_ref: str | None,
route_ref: str,
) -> str:
if not route_ref.startswith("#"):
return route_ref
if not base_ref:
raise ValueError("OpenAPI metadata reference must include a JSON file path.")
return f"{_wasm_openapi_document_path(base_ref)}{route_ref}"
def _wasm_openapi_document_path(openapi_ref: str) -> str:
document_path, _, _ = openapi_ref.partition("#")
if not document_path:
raise ValueError("OpenAPI metadata reference must include a JSON file path.")
return document_path
def _wasm_openapi_route_key(export_name: str) -> str:
return re.sub(r"[^A-Za-z0-9]+", "_", export_name).strip("_").lower()
def _json_pointer_token(value: str) -> str:
return value.replace("~", "~0").replace("/", "~1")
def _wasm_openapi_ref_parts(openapi_ref: str) -> tuple[str, str]:
document_path, _, pointer = openapi_ref.partition("#")
if not document_path:
raise ValueError("OpenAPI metadata reference must include a JSON file path.")
return document_path, pointer
def _load_wasm_openapi_document(
extension: WasmExtension,
document_path: str,
) -> dict[str, Any]:
if "://" in document_path or document_path.startswith(("/", "\\")):
raise ValueError("OpenAPI metadata reference must be a local relative path.")
if not document_path.lower().endswith(".json"):
raise ValueError("OpenAPI metadata reference must point to a JSON file.")
extension_root = extension.root_path.resolve()
path = (extension_root / document_path).resolve()
if not path.is_relative_to(extension_root):
raise ValueError("OpenAPI metadata reference escapes the extension root.")
if not path.is_file():
raise FileNotFoundError(f"OpenAPI metadata file not found: {document_path}")
with path.open("r", encoding="utf-8") as openapi_file:
document = json.load(openapi_file)
if not isinstance(document, dict):
raise TypeError("OpenAPI metadata file must contain a JSON object.")
return document
def _resolve_json_pointer(document: Any, pointer: str) -> Any:
if not pointer:
return document
if not pointer.startswith("/"):
raise ValueError("OpenAPI metadata reference must use a JSON pointer.")
value = document
for raw_token in pointer[1:].split("/"):
token = raw_token.replace("~1", "/").replace("~0", "~")
if isinstance(value, dict):
value = value[token]
elif isinstance(value, list):
value = value[int(token)]
else:
raise KeyError(token)
return value
def _inline_wasm_openapi_refs(
value: Any,
document: dict[str, Any],
seen_refs: tuple[str, ...] = (),
) -> Any:
if isinstance(value, list):
return [_inline_wasm_openapi_refs(item, document, seen_refs) for item in value]
if not isinstance(value, dict):
return value
ref = value.get("$ref")
if isinstance(ref, str) and ref.startswith("#/") and ref not in seen_refs:
try:
resolved = _inline_wasm_openapi_refs(
deepcopy(_resolve_json_pointer(document, ref[1:])),
document,
(*seen_refs, ref),
)
except Exception:
resolved = None
if resolved is not None:
overrides = {
key: _inline_wasm_openapi_refs(item, document, seen_refs)
for key, item in value.items()
if key != "$ref"
}
if isinstance(resolved, dict):
return {**resolved, **overrides}
if not overrides:
return resolved
return {
key: _inline_wasm_openapi_refs(item, document, seen_refs)
for key, item in value.items()
}
def _add_wasm_openapi_success_examples(operation: dict[str, Any]) -> None:
responses = operation.get("responses")
if not isinstance(responses, dict):
return
for response in responses.values():
if not isinstance(response, dict):
continue
content = response.get("content")
if not isinstance(content, dict):
continue
json_content = content.get("application/json")
if not isinstance(json_content, dict):
continue
if "example" in json_content or "examples" in json_content:
continue
schema = json_content.get("schema")
example = _wasm_openapi_success_example(schema)
if example is not None:
json_content["example"] = example
def _wasm_openapi_success_example(schema: Any) -> Any | None:
if not isinstance(schema, dict):
return None
for keyword in ("oneOf", "anyOf"):
variants = schema.get(keyword)
if not isinstance(variants, list):
continue
for variant in variants:
if _wasm_openapi_schema_has_ok_value(variant, True):
return _wasm_openapi_schema_example(variant)
return None
def _wasm_openapi_schema_has_ok_value(schema: Any, ok_value: bool) -> bool:
if not isinstance(schema, dict):
return False
properties = schema.get("properties")
if isinstance(properties, dict):
ok_schema = properties.get("ok")
if isinstance(ok_schema, dict):
enum = ok_schema.get("enum")
return isinstance(enum, list) and ok_value in enum
all_of = schema.get("allOf")
return isinstance(all_of, list) and any(
_wasm_openapi_schema_has_ok_value(item, ok_value) for item in all_of
)
def _wasm_openapi_schema_example(schema: Any) -> Any:
if not isinstance(schema, dict):
return None
explicit_example = _wasm_openapi_explicit_schema_example(schema)
if explicit_example is not _MISSING_OPENAPI_EXAMPLE:
return explicit_example
composed_example = _wasm_openapi_composed_schema_example(schema)
if composed_example is not _MISSING_OPENAPI_EXAMPLE:
return composed_example
return _wasm_openapi_type_schema_example(schema)
def _wasm_openapi_explicit_schema_example(schema: dict[str, Any]) -> Any:
if "example" in schema:
return schema["example"]
enum = schema.get("enum")
if isinstance(enum, list) and enum:
return enum[0]
return _MISSING_OPENAPI_EXAMPLE
def _wasm_openapi_composed_schema_example(schema: dict[str, Any]) -> Any:
all_of = schema.get("allOf")
if isinstance(all_of, list):
example: dict[str, Any] = {}
for item in all_of:
item_example = _wasm_openapi_schema_example(item)
if isinstance(item_example, dict):
example.update(item_example)
return example
for keyword in ("oneOf", "anyOf"):
variants = schema.get(keyword)
if isinstance(variants, list) and variants:
return _wasm_openapi_schema_example(variants[0])
return _MISSING_OPENAPI_EXAMPLE
def _wasm_openapi_type_schema_example(schema: dict[str, Any]) -> Any:
schema_type = schema.get("type")
if schema_type == "object" or isinstance(schema.get("properties"), dict):
properties = schema.get("properties")
if not isinstance(properties, dict):
return {}
return {
name: _wasm_openapi_schema_example(property_schema)
for name, property_schema in properties.items()
}
if schema_type == "array":
return [_wasm_openapi_schema_example(schema.get("items"))]
if schema_type == "integer":
return 0
if schema_type == "number":
return 0
if schema_type == "boolean":
return True
return "string"
def _openapi_string(value: Any) -> str | None:
if not isinstance(value, str):
return None
value = value.strip()
return value or None
def _wasm_extension_default_operation_id(
extension: WasmExtension,
route_config: WasmAPIRouteConfig,
method: str,
) -> str:
value = f"{extension.id}_{method}_{route_config.path}"
value = re.sub(r"[^A-Za-z0-9]+", "_", value).strip("_").lower()
return value or f"{extension.id}_{method.lower()}"

View file

@ -8,7 +8,10 @@ from lnbits.settings import settings
from ..wasm.component import warm_wasm_extension
from ..wasm.loader import WasmExtension, load_wasm_extension
from .api import register_wasm_extension_api_routes
from .api import (
register_wasm_extension_api_routes,
unregister_wasm_extension_api_routes,
)
from .assets import mount_wasm_extension_static
from .ui import register_wasm_extension_ui_routes
@ -30,3 +33,10 @@ def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
f"({loaded.module_path.stat().st_size} bytes)."
)
return loaded
def unregister_wasm_extension(app: FastAPI, ext_id: str) -> None:
routes_removed = unregister_wasm_extension_api_routes(app, ext_id)
core_app_extra.wasm_extension_registry.unregister(ext_id)
if routes_removed:
logger.info(f"Unloaded WASM extension API routes for '{ext_id}'.")

View file

@ -62,6 +62,7 @@ class WasmAPIRouteConfig(_StrictWasmModel):
auth: Literal["public", "user"]
path_params: dict[str, StrictStr] = Field(default_factory=dict)
owner_context: WasmRouteOwnerContext | None = Field(None, alias="ownerContext")
openapi: StrictStr | None = None
class WasmEventsConfig(_StrictWasmModel):
@ -83,6 +84,7 @@ class WasmExtensionConfig(_StrictWasmModel):
)
ui: WasmUIConfig | None = None
sdk: WasmSDKConfig | None = None
openapi: StrictStr | None = None
ui_routes: list[WasmUIRouteConfig] = Field(default_factory=list)
api_routes: list[WasmAPIRouteConfig] = Field(default_factory=list)
permissions: list[ExtensionPermission] = Field(default_factory=list)

File diff suppressed because one or more lines are too long

View file

@ -1217,13 +1217,23 @@ window.WasmExtensionComponent = {
}
const subscriptionId = String(message.subscriptionId || '')
if (!subscriptionId) {
throw new Error('Invalid websocket subscription.')
}
const subscription = this.websocketSubscriptions.get(subscriptionId)
if (!subscription) {
throw new Error('Unknown websocket subscription.')
return
}
if (subscription.socket.readyState !== WebSocket.OPEN) {
throw new Error('Websocket subscription is not open.')
if (
subscription.socket.readyState === WebSocket.CLOSING ||
subscription.socket.readyState === WebSocket.CLOSED
) {
this.closeWebsocketSubscription(subscriptionId)
}
return
}
const data =

View file

@ -261,6 +261,45 @@ async def test_uninstall_activate_and_deactivate_extensions(
assert start_mock.await_count == 1
@pytest.mark.anyio
async def test_uninstall_wasm_extension_unregisters_live_routes(
tmp_path, settings: Settings, mocker: MockerFixture
):
ext_id = f"wasm_{uuid4().hex[:8]}"
ext_info = make_installable_extension(ext_id)
original_data_folder = settings.lnbits_data_folder
original_extensions_path = settings.lnbits_extensions_path
original_deactivated = set(settings.lnbits_deactivated_extensions)
unregister_routes_mock = mocker.patch(
"lnbits.core.services.extensions.core_app_extra.unregister_wasm_ext_routes"
)
clean_mock = mocker.patch.object(InstallableExtension, "clean_extension_files")
try:
settings.lnbits_data_folder = str(tmp_path / "data")
settings.lnbits_extensions_path = str(tmp_path / "code")
ext_dir = tmp_path / "code" / "extensions" / ext_id
ext_dir.mkdir(parents=True, exist_ok=True)
(ext_dir / "config.json").write_text(
json.dumps(_wasm_install_config(ext_id)),
encoding="utf-8",
)
await create_installed_extension(ext_info)
await uninstall_extension(ext_id)
assert await get_installed_extension(ext_id) is None
assert ext_id in settings.lnbits_deactivated_extensions
finally:
await delete_installed_extension(ext_id=ext_id)
settings.lnbits_data_folder = original_data_folder
settings.lnbits_extensions_path = original_extensions_path
settings.lnbits_deactivated_extensions = original_deactivated
unregister_routes_mock.assert_called_once_with(ext_id)
clean_mock.assert_called_once()
@pytest.mark.anyio
async def test_wasm_invocation_monitoring_marks_stale_once_and_cleans_periodically(
settings: Settings,

View file

@ -42,6 +42,8 @@ def test_wasm_frontend_bridge_restricts_api_routes_and_realtime_actions():
assert "message.action === 'websocket.unsubscribe'" in bridge
assert "message.action === 'websocket.send'" in bridge
assert "sendWebsocket(message)" in bridge
assert "Unknown websocket subscription." not in bridge
assert "if (!subscription) {\n return\n }" in bridge
assert "message.action === 'navigation.replace'" in bridge
assert "message.action === 'navigation.open_new_tab'" in bridge
assert "openNewTab(message)" in bridge

View file

@ -104,6 +104,7 @@ def test_wasm_extension_config_accepts_supported_optional_sections():
{"name": "on_invoice_paid", "visibility": "event"},
],
},
"openapi": "wasm/openapi.json",
"events": {"onInvoicePaid": "on_invoice_paid"},
"ui": {"entrypoint": "static/index.html", "sandbox": True},
"sdk": {"frontend_js": "static/lnbits-extension-sdk.js"},
@ -122,6 +123,7 @@ def test_wasm_extension_config_accepts_supported_optional_sections():
"export": "render",
"auth": "public",
"path_params": {"item_id": "str"},
"openapi": "#/routes/render",
}
],
"permissions": [{"id": "utils.basic", "description": "Basic utils"}],
@ -132,6 +134,8 @@ def test_wasm_extension_config_accepts_supported_optional_sections():
assert parsed.events.on_invoice_paid == "on_invoice_paid"
assert parsed.wasm.world == "lnbits-extension"
assert parsed.openapi == "wasm/openapi.json"
assert parsed.api_routes[0].openapi == "#/routes/render"
def test_wasm_extension_config_ignores_unknown_permission_fields():

View file

@ -1,11 +1,12 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from pathlib import Path
from typing import cast
import pytest
from fastapi import HTTPException, Request
from fastapi import FastAPI, HTTPException, Request
from lnbits.core.wasm_ext.routes.api import (
WasmRequestBodyTooLargeError,
@ -14,6 +15,8 @@ from lnbits.core.wasm_ext.routes.api import (
_read_json_object_with_size,
_wasm_extension_api_export,
_wasm_route_owner_id,
register_wasm_extension_api_routes,
unregister_wasm_extension_api_routes,
)
from lnbits.core.wasm_ext.routes.assets import (
WASM_EXTENSION_STATIC_MIME_TYPES,
@ -29,6 +32,7 @@ from lnbits.core.wasm_ext.routes.ui import (
_match_wasm_extension_ui_route,
_wasm_extension_bridge_api_routes,
_wasm_extension_entrypoint,
register_wasm_extension_ui_routes,
)
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
from lnbits.core.wasm_ext.wasm.loader import WasmExtension
@ -173,6 +177,342 @@ def test_wasm_ui_route_matching_and_bridge_public_api_filtering(tmp_path: Path):
}
def test_wasm_api_routes_are_included_in_openapi(tmp_path: Path):
app = FastAPI()
app.openapi_schema = {"stale": True}
register_wasm_extension_api_routes(app, _wasm_extension(tmp_path))
assert app.openapi_schema is None
schema = app.openapi()
assert "/api/v1/ext/demoext/public/{item_id}" in schema["paths"]
assert "/api/v1/ext/demoext/private/{item_id}" in schema["paths"]
assert schema["paths"]["/api/v1/ext/demoext/public/{item_id}"]["get"]["tags"] == [
"Demo"
]
assert schema["paths"]["/api/v1/ext/demoext/private/{item_id}"]["post"]["tags"] == [
"Demo"
]
def test_wasm_api_routes_load_openapi_operation_fragment(tmp_path: Path):
app = FastAPI()
openapi_dir = tmp_path / "wasm"
openapi_dir.mkdir()
(openapi_dir / "openapi.json").write_text(
json.dumps(
{
"schemas": {
"DemoItem": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
},
}
},
"routes": {
"list_demo_items": {
"summary": "List demo items",
"description": "Returns demo items.",
"operationId": "demoext_list_demo_items",
"tags": ["Ignored"],
"responses": {
"200": {
"description": "Demo item list",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/schemas/DemoItem"
},
}
},
}
}
},
}
},
}
},
}
),
encoding="utf-8",
)
register_wasm_extension_api_routes(
app,
_wasm_extension(
tmp_path,
openapi="wasm/openapi.json",
extra_exports=[{"name": "list-demo-items", "visibility": "public"}],
api_routes=[
{
"method": "GET",
"path": "/public/{item_id}",
"export": "list-demo-items",
"auth": "public",
}
],
),
)
operation = app.openapi()["paths"]["/api/v1/ext/demoext/public/{item_id}"]["get"]
item_schema = operation["responses"]["200"]["content"]["application/json"][
"schema"
]["properties"]["items"]["items"]
assert operation["summary"] == "List demo items"
assert operation["description"] == "Returns demo items."
assert operation["operationId"] == "demoext_list_demo_items"
assert operation["tags"] == ["Demo"]
assert item_schema["properties"]["name"] == {"type": "string"}
def test_wasm_api_routes_allow_route_openapi_fragment_override(tmp_path: Path):
app = FastAPI()
openapi_dir = tmp_path / "wasm"
openapi_dir.mkdir()
(openapi_dir / "openapi.json").write_text(
json.dumps(
{
"routes": {
"render": {
"summary": "Default render docs",
"operationId": "demoext_render",
},
"custom-render": {
"summary": "Custom render docs",
"operationId": "demoext_custom_render",
},
}
}
),
encoding="utf-8",
)
register_wasm_extension_api_routes(
app,
_wasm_extension(
tmp_path,
openapi="wasm/openapi.json",
api_routes=[
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
"openapi": "#/routes/custom-render",
}
],
),
)
operation = app.openapi()["paths"]["/api/v1/ext/demoext/public/{item_id}"]["get"]
assert operation["summary"] == "Custom render docs"
assert operation["operationId"] == "demoext_custom_render"
def test_wasm_api_routes_add_success_response_example_for_redoc(tmp_path: Path):
app = FastAPI()
openapi_dir = tmp_path / "wasm"
openapi_dir.mkdir()
(openapi_dir / "openapi.json").write_text(
json.dumps(
{
"schemas": {
"DemoItem": {
"type": "object",
"properties": {
"id": {"type": "string"},
"count": {"type": "integer"},
},
},
"DemoResponse": {
"type": "object",
"required": ["ok", "data"],
"properties": {
"ok": {"type": "boolean", "enum": [True]},
"data": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"$ref": "#/schemas/DemoItem"},
}
},
},
},
},
"ErrorResponse": {
"type": "object",
"required": ["ok", "error"],
"properties": {
"ok": {"type": "boolean", "enum": [False]},
"error": {"type": "string"},
},
},
},
"routes": {
"render": {
"summary": "Render",
"responses": {
"200": {
"description": "Success or extension-level error.",
"content": {
"application/json": {
"schema": {
"oneOf": [
{"$ref": "#/schemas/DemoResponse"},
{"$ref": "#/schemas/ErrorResponse"},
]
}
}
},
}
},
}
},
}
),
encoding="utf-8",
)
register_wasm_extension_api_routes(
app,
_wasm_extension(
tmp_path,
openapi="wasm/openapi.json",
api_routes=[
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
}
],
),
)
json_content = app.openapi()["paths"]["/api/v1/ext/demoext/public/{item_id}"][
"get"
]["responses"]["200"]["content"]["application/json"]
assert json_content["example"] == {
"ok": True,
"data": {"items": [{"id": "string", "count": 0}]},
}
def test_wasm_api_routes_ignore_missing_openapi_fragment(tmp_path: Path):
app = FastAPI()
register_wasm_extension_api_routes(
app,
_wasm_extension(
tmp_path,
api_routes=[
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
"openapi": "wasm/missing.json#/routes/list_demo_items",
}
],
),
)
operation = app.openapi()["paths"]["/api/v1/ext/demoext/public/{item_id}"]["get"]
assert operation["summary"] == "GET /public/{item_id}"
assert operation["operationId"] == "demoext_get_public_item_id"
def test_wasm_api_routes_replace_same_extension_routes_on_upgrade(tmp_path: Path):
app = FastAPI()
route_path = "/api/v1/ext/demoext/public/{item_id}"
async def legacy_handler() -> dict[str, bool]:
return {"legacy": True}
app.add_api_route(
route_path,
legacy_handler,
methods=["GET"],
name=f"demoext:GET:{route_path}",
include_in_schema=False,
)
app.openapi_schema = {"stale": True}
register_wasm_extension_api_routes(app, _wasm_extension(tmp_path))
routes = _matching_routes(app, route_path, "GET")
assert len(routes) == 1
assert routes[0].endpoint != legacy_handler
assert routes[0].include_in_schema is True
assert app.openapi_schema is None
assert route_path in app.openapi()["paths"]
def test_wasm_api_routes_remove_obsolete_routes_on_upgrade(tmp_path: Path):
app = FastAPI()
route_path = "/api/v1/ext/demoext/removed"
async def removed_handler() -> dict[str, bool]:
return {"removed": True}
app.add_api_route(
route_path,
removed_handler,
methods=["GET"],
name=f"demoext:GET:{route_path}",
)
app.openapi_schema = {"stale": True}
register_wasm_extension_api_routes(app, _wasm_extension(tmp_path))
assert _matching_routes(app, route_path, "GET") == []
assert app.openapi_schema is None
assert route_path not in app.openapi()["paths"]
def test_wasm_api_route_cleanup_preserves_ui_frame_config_route(tmp_path: Path):
app = FastAPI()
(tmp_path / "index.html").write_text("<html></html>", encoding="utf-8")
extension = _wasm_extension(tmp_path)
frame_config_path = "/api/v1/ext/demoext/_ui/frame"
api_route_path = "/api/v1/ext/demoext/public/{item_id}"
register_wasm_extension_ui_routes(app, extension)
assert _matching_routes(app, frame_config_path, "POST") != []
register_wasm_extension_api_routes(app, extension)
assert _matching_routes(app, frame_config_path, "POST") != []
assert _matching_routes(app, api_route_path, "GET") != []
assert unregister_wasm_extension_api_routes(app, "demoext") is True
assert _matching_routes(app, api_route_path, "GET") == []
assert _matching_routes(app, frame_config_path, "POST") != []
def test_wasm_api_routes_are_removed_from_openapi_on_uninstall(tmp_path: Path):
app = FastAPI()
route_path = "/api/v1/ext/demoext/public/{item_id}"
register_wasm_extension_api_routes(app, _wasm_extension(tmp_path))
assert route_path in app.openapi()["paths"]
assert unregister_wasm_extension_api_routes(app, "demoext") is True
assert app.openapi_schema is None
assert _matching_routes(app, route_path, "GET") == []
assert route_path not in app.openapi()["paths"]
@pytest.mark.anyio
async def test_wasm_api_route_owner_context_uses_configured_storage_row(
tmp_path: Path, mocker
@ -260,46 +600,54 @@ class _FakeRequest:
yield chunk
def _wasm_extension(root_path: Path) -> WasmExtension:
config = parse_wasm_extension_config(
"demoext",
{
"id": "demoext",
"name": "Demo",
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {
"module": "extension.wasm",
"exports": [
{"name": "render", "visibility": "public"},
{"name": "private_render", "visibility": "authenticated"},
{"name": "on_invoice_paid", "visibility": "event"},
],
},
"ui_routes": [
{
"path": "/demo/{item_id}",
"entrypoint": "index.html",
"auth": "user",
}
],
"api_routes": [
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
},
{
"method": "POST",
"path": "/private/{item_id}",
"export": "private_render",
"auth": "user",
},
def _wasm_extension(
root_path: Path,
*,
api_routes: list[dict] | None = None,
openapi: str | None = None,
extra_exports: list[dict] | None = None,
) -> WasmExtension:
extension_config = {
"id": "demoext",
"name": "Demo",
"short_description": "Demo extension",
"version": "1.0.0",
"extension_type": "wasm",
"wasm": {
"module": "extension.wasm",
"exports": [
{"name": "render", "visibility": "public"},
{"name": "private_render", "visibility": "authenticated"},
{"name": "on_invoice_paid", "visibility": "event"},
*(extra_exports or []),
],
},
)
"ui_routes": [
{
"path": "/demo/{item_id}",
"entrypoint": "index.html",
"auth": "user",
}
],
"api_routes": api_routes
or [
{
"method": "GET",
"path": "/public/{item_id}",
"export": "render",
"auth": "public",
},
{
"method": "POST",
"path": "/private/{item_id}",
"export": "private_render",
"auth": "user",
},
],
}
if openapi:
extension_config["openapi"] = openapi
config = parse_wasm_extension_config("demoext", extension_config)
return WasmExtension(
id="demoext",
name="Demo",
@ -325,3 +673,12 @@ def _request_with_query(token: str) -> Request:
"headers": [],
}
)
def _matching_routes(app: FastAPI, route_path: str, method: str) -> list:
return [
route
for route in app.router.routes
if getattr(route, "path", None) == route_path
and method in (getattr(route, "methods", set()) or set())
]

View file

@ -10,16 +10,23 @@ from lnbits.core.wasm_ext.api.websockets import (
class FakeWebSocket:
def __init__(self, received: list[str] | None = None):
def __init__(
self,
received: list[str] | None = None,
send_error: Exception | None = None,
):
self.accepted = False
self.sent: list[str] = []
self.closed: int | None = None
self.received = list(received or [])
self.send_error = send_error
async def accept(self):
self.accepted = True
async def send_text(self, data: str):
if self.send_error:
raise self.send_error
self.sent.append(data)
async def receive_text(self):
@ -55,6 +62,26 @@ async def test_wasm_extension_websocket_hub_publishes_to_matching_channel():
assert other_extension.sent == []
@pytest.mark.anyio
async def test_wasm_extension_websocket_hub_prunes_stale_publish_connections():
hub = WasmExtensionWebsocketHub()
stale = FakeWebSocket(send_error=RuntimeError("websocket closed"))
active = FakeWebSocket()
await hub.connect("demoext", "room-1", cast(WebSocket, stale))
await hub.connect("demoext", "room-1", cast(WebSocket, active))
await hub.publish(
"demoext",
"room-1",
'{"message":"Hello"}',
max_messages_per_second=10,
)
assert active.sent == ['{"message":"Hello"}']
assert hub.get_connections("demoext", "room-1")[0].websocket == active
@pytest.mark.anyio
async def test_wasm_extension_websocket_hub_rate_limits_per_channel():
hub = WasmExtensionWebsocketHub()