feat: call ext logic
This commit is contained in:
@@ -11,6 +11,7 @@ from .api import (
|
||||
from .loader import WasmExtension, load_wasm_extension, register_wasm_extension
|
||||
from .prototype import InMemoryExtensionAPI, InMemoryExtensionState
|
||||
from .runtime import ExtensionAPIHost
|
||||
from .wasm import invoke_wasm_extension_export
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPI",
|
||||
@@ -22,6 +23,7 @@ __all__ = [
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"get_extension_api_method",
|
||||
"invoke_wasm_extension_export",
|
||||
"load_wasm_extension",
|
||||
"list_extension_api_methods",
|
||||
"register_wasm_extension",
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
@@ -46,6 +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)
|
||||
|
||||
extensions = getattr(app.state, "lnbits_wasm_extensions", {})
|
||||
extensions[ext_id] = loaded
|
||||
@@ -113,6 +114,67 @@ 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(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
) -> 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):
|
||||
return
|
||||
|
||||
async def invoke_wasm_extension_export(
|
||||
export_name: str,
|
||||
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)
|
||||
return await invoke_export(
|
||||
app,
|
||||
extension.id,
|
||||
export_name,
|
||||
payload,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
invoke_wasm_extension_export,
|
||||
methods=["POST"],
|
||||
name=f"{extension.id}:invoke",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
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.")
|
||||
return value
|
||||
|
||||
|
||||
def _require_http_wasm_export(extension: WasmExtension, export_name: str) -> None:
|
||||
for export in extension.exports:
|
||||
if export.get("name") != export_name:
|
||||
continue
|
||||
if export.get("visibility") in {"public", "authenticated"}:
|
||||
return
|
||||
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 _add_wasm_extension_page_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .api import list_extension_api_methods
|
||||
from .loader import WasmExtension, register_wasm_extension
|
||||
from .prototype import InMemoryExtensionAPI, InMemoryExtensionState
|
||||
from .runtime import ExtensionAPIHost
|
||||
|
||||
|
||||
async def invoke_wasm_extension_export(
|
||||
app: FastAPI,
|
||||
ext_id: str,
|
||||
export_name: str,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
extension = _get_registered_extension(app, ext_id)
|
||||
state = _get_extension_state(app)
|
||||
permissions = _extension_permissions(extension)
|
||||
api = InMemoryExtensionAPI(extension.id, permissions, state=state)
|
||||
|
||||
return await asyncio.to_thread(
|
||||
_invoke_wasm_extension_export_sync,
|
||||
extension,
|
||||
export_name,
|
||||
payload or {},
|
||||
api,
|
||||
)
|
||||
|
||||
|
||||
def _invoke_wasm_extension_export_sync(
|
||||
extension: WasmExtension,
|
||||
export_name: str,
|
||||
payload: Mapping[str, Any],
|
||||
api: InMemoryExtensionAPI,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
from wasmtime import Store, WasiConfig, component
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"WASM extension runtime is not installed. Install the 'wasmtime' "
|
||||
"Python package to run WASM extensions."
|
||||
) from exc
|
||||
|
||||
engine = _wasm_engine()
|
||||
store = Store(engine)
|
||||
store.set_wasi(WasiConfig())
|
||||
|
||||
linker = component.Linker(engine)
|
||||
linker.add_wasip2()
|
||||
_add_extension_host_imports(linker, ExtensionAPIHost(api))
|
||||
|
||||
wasm_component = component.Component.from_file(engine, extension.module_path)
|
||||
instance = linker.instantiate(store, wasm_component)
|
||||
function = instance.get_func(store, export_name)
|
||||
if not function:
|
||||
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
|
||||
|
||||
result = function(store, json.dumps(payload))
|
||||
function.post_return(store)
|
||||
return _parse_wasm_export_result(extension, result)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _wasm_engine() -> Any:
|
||||
try:
|
||||
from wasmtime import Config, Engine
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"WASM extension runtime is not installed. Install the 'wasmtime' "
|
||||
"Python package to run WASM extensions."
|
||||
) from exc
|
||||
|
||||
config = Config()
|
||||
config.wasm_component_model = True
|
||||
return Engine(config)
|
||||
|
||||
|
||||
def _add_extension_host_imports(linker: Any, api_host: ExtensionAPIHost) -> None:
|
||||
with linker.root() as root:
|
||||
with root.add_instance("lnbits:extension/host") as host:
|
||||
for method in list_extension_api_methods():
|
||||
host.add_func(
|
||||
method.host_name.replace("_", "-"),
|
||||
_make_host_import(api_host, method.host_name),
|
||||
)
|
||||
|
||||
|
||||
def _make_host_import(api_host: ExtensionAPIHost, host_name: str) -> Any:
|
||||
def host_import(_store: Any, request: Any = None) -> Any:
|
||||
payload = _component_payload_to_dict(request)
|
||||
response = asyncio.run(api_host.invoke(host_name, payload))
|
||||
return _dict_to_component_record(response)
|
||||
|
||||
return host_import
|
||||
|
||||
|
||||
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if hasattr(value, "__dict__"):
|
||||
return dict(value.__dict__)
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value)
|
||||
raise TypeError("WASM host function payload must be a record.")
|
||||
|
||||
|
||||
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
|
||||
from wasmtime import component
|
||||
|
||||
record = component.Record()
|
||||
for key, item in value.items():
|
||||
setattr(record, _camel_to_kebab(key), item)
|
||||
return record
|
||||
|
||||
|
||||
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
if not isinstance(value, str):
|
||||
return {"ok": True, "data": value}
|
||||
|
||||
max_response_bytes = (
|
||||
(extension.config.get("wasm") or {})
|
||||
.get("resource_limits", {})
|
||||
.get("max_response_bytes")
|
||||
)
|
||||
if isinstance(max_response_bytes, int):
|
||||
response_size = len(value.encode())
|
||||
if response_size > max_response_bytes:
|
||||
raise ValueError(
|
||||
f"WASM extension response is too large: {response_size} bytes."
|
||||
)
|
||||
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return {"ok": True, "data": parsed}
|
||||
|
||||
|
||||
def _get_registered_extension(app: FastAPI, ext_id: str) -> WasmExtension:
|
||||
extensions = getattr(app.state, "lnbits_wasm_extensions", {})
|
||||
extension = extensions.get(ext_id)
|
||||
if extension:
|
||||
return extension
|
||||
return register_wasm_extension(app, ext_id)
|
||||
|
||||
|
||||
def _get_extension_state(app: FastAPI) -> InMemoryExtensionState:
|
||||
state = getattr(app.state, "lnbits_extension_state", None)
|
||||
if not state:
|
||||
state = InMemoryExtensionState()
|
||||
app.state.lnbits_extension_state = state
|
||||
return state
|
||||
|
||||
|
||||
def _extension_permissions(extension: WasmExtension) -> set[str]:
|
||||
permissions = set()
|
||||
for permission in extension.config.get("permissions") or []:
|
||||
if isinstance(permission, Mapping) and isinstance(permission.get("id"), str):
|
||||
permissions.add(permission["id"])
|
||||
return permissions
|
||||
|
||||
|
||||
def _camel_to_kebab(value: str) -> str:
|
||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
|
||||
Reference in New Issue
Block a user