From b167647b74fcdf9855cdc18cdc73e687dc2ef876 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 23 Jun 2026 15:10:12 +0300 Subject: [PATCH] fix: REST paths --- lnbits/core/extensions/loader.py | 97 +++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/lnbits/core/extensions/loader.py b/lnbits/core/extensions/loader.py index 9763132ea..4a825d973 100644 --- a/lnbits/core/extensions/loader.py +++ b/lnbits/core/extensions/loader.py @@ -46,7 +46,7 @@ def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension: loaded = load_wasm_extension(ext_id) _mount_wasm_extension_static(app, loaded) _register_wasm_extension_routes(app, loaded) - _register_wasm_extension_invoke_route(app, loaded) + _register_wasm_extension_api_routes(app, loaded) extensions = getattr(app.state, "lnbits_wasm_extensions", {}) extensions[ext_id] = loaded @@ -114,23 +114,29 @@ def _register_wasm_extension_routes(app: FastAPI, extension: WasmExtension) -> N _add_wasm_extension_page_route(app, extension, route_path, entrypoint) -def _register_wasm_extension_invoke_route( +def _register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None: + for route_config in extension.config.get("api_routes") or []: + _add_wasm_extension_api_route(app, extension, route_config) + + +def _add_wasm_extension_api_route( app: FastAPI, extension: WasmExtension, + route_config: dict[str, Any], ) -> None: - route_path = f"/api/v1/extensions/{extension.id}/invoke/{{export_name}}" - if any(getattr(route, "path", None) == route_path for route in app.routes): + method = _wasm_extension_api_method(extension, route_config.get("method")) + route_path = _wasm_extension_api_path(extension, route_config.get("path")) + export_name = _wasm_extension_api_export(extension, route_config.get("export")) + path_params = route_config.get("path_params") or {} + + if _has_route(app, route_path, method): return - async def invoke_wasm_extension_export( - export_name: str, - request: Request, - ) -> dict[str, Any]: + async def invoke_wasm_extension_export(request: Request) -> dict[str, Any]: from .wasm import invoke_wasm_extension_export as invoke_export try: - _require_http_wasm_export(extension, export_name) - payload = await _read_json_object(request) + payload = await _read_api_payload(request, path_params) return await invoke_export( app, extension.id, @@ -147,34 +153,95 @@ def _register_wasm_extension_invoke_route( app.add_api_route( route_path, invoke_wasm_extension_export, - methods=["POST"], - name=f"{extension.id}:invoke", + methods=[method], + name=f"{extension.id}:{method}:{route_path}", include_in_schema=False, ) +async def _read_api_payload( + request: Request, + path_params: dict[str, str], +) -> dict[str, Any]: + payload = _read_api_path_params(request, path_params) + payload.update(_read_api_query_params(request)) + if request.method in {"POST", "PUT", "PATCH"}: + payload.update(await _read_json_object(request)) + return payload + + async def _read_json_object(request: Request) -> dict[str, Any]: body = await request.body() if not body: return {} value = json.loads(body) if not isinstance(value, dict): - raise TypeError("WASM extension invoke payload must be a JSON object.") + raise TypeError("WASM extension API payload must be a JSON object.") return value -def _require_http_wasm_export(extension: WasmExtension, export_name: str) -> None: +def _read_api_path_params( + request: Request, + path_params: dict[str, str], +) -> dict[str, Any]: + payload: dict[str, Any] = {} + for key, value in request.path_params.items(): + target = path_params.get(key) or _snake_to_camel(key) + payload[target] = value + return payload + + +def _read_api_query_params(request: Request) -> dict[str, Any]: + return {_snake_to_camel(key): value for key, value in request.query_params.items()} + + +def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str: + if not isinstance(export_name, str) or not export_name: + raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.") + for export in extension.exports: if export.get("name") != export_name: continue if export.get("visibility") in {"public", "authenticated"}: - return + return export_name raise PermissionError( f"WASM export '{export_name}' is not callable over HTTP." ) raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.") +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"}: + raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.") + return method + + +def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str: + if not isinstance(path, str) or not path.startswith("/"): + raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.") + if path == "/": + return f"/api/v1/ext/{extension.id}" + return f"/api/v1/ext/{extension.id}{path}" + + +def _has_route(app: FastAPI, route_path: str, method: str) -> bool: + for route in app.routes: + if getattr(route, "path", None) != route_path: + continue + methods = getattr(route, "methods", set()) or set() + if method in methods: + return True + return False + + +def _snake_to_camel(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.capitalize() for part in tail) + + def _add_wasm_extension_page_route( app: FastAPI, extension: WasmExtension,