mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
feat: add wasm to open api docs (#4074)
This commit is contained in:
parent
9177e2262a
commit
c5b5651d8f
16 changed files with 1006 additions and 63 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
356
lnbits/core/wasm_ext/routes/open_api.py
Normal file
356
lnbits/core/wasm_ext/routes/open_api.py
Normal 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()}"
|
||||
|
|
@ -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}'.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue