Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id43ab8edfd56196d376d72ecc136f6086a6a6964
345 lines
9.9 KiB
Rust
345 lines
9.9 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Path, State},
|
|
};
|
|
use pinakes_plugin::PluginManager;
|
|
use rustc_hash::FxHashMap;
|
|
|
|
use crate::{
|
|
dto::{
|
|
InstallPluginRequest,
|
|
PluginEventRequest,
|
|
PluginResponse,
|
|
PluginUiPageEntry,
|
|
PluginUiWidgetEntry,
|
|
TogglePluginRequest,
|
|
},
|
|
error::ApiError,
|
|
state::AppState,
|
|
};
|
|
|
|
fn require_plugin_manager(
|
|
state: &AppState,
|
|
) -> Result<Arc<PluginManager>, ApiError> {
|
|
state.plugin_manager.clone().ok_or_else(|| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
"Plugin system is not enabled".to_string(),
|
|
))
|
|
})
|
|
}
|
|
|
|
/// List all installed plugins
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/plugins",
|
|
tag = "plugins",
|
|
responses(
|
|
(status = 200, description = "List of plugins", body = Vec<PluginResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn list_plugins(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<PluginResponse>>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
let plugins = plugin_manager.list_plugins().await;
|
|
let mut responses = Vec::with_capacity(plugins.len());
|
|
for meta in plugins {
|
|
let enabled = plugin_manager.is_plugin_enabled(&meta.id).await;
|
|
responses.push(PluginResponse::new(meta, enabled));
|
|
}
|
|
Ok(Json(responses))
|
|
}
|
|
|
|
/// Get a specific plugin by ID
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/plugins/{id}",
|
|
tag = "plugins",
|
|
params(("id" = String, Path, description = "Plugin ID")),
|
|
responses(
|
|
(status = 200, description = "Plugin details", body = PluginResponse),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn get_plugin(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<PluginResponse>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
let plugin = plugin_manager.get_plugin(&id).await.ok_or_else(|| {
|
|
ApiError(pinakes_core::error::PinakesError::NotFound(format!(
|
|
"Plugin not found: {id}"
|
|
)))
|
|
})?;
|
|
|
|
let enabled = plugin_manager.is_plugin_enabled(&id).await;
|
|
Ok(Json(PluginResponse::new(plugin, enabled)))
|
|
}
|
|
|
|
/// Install a plugin from URL or file path
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/plugins",
|
|
tag = "plugins",
|
|
request_body = InstallPluginRequest,
|
|
responses(
|
|
(status = 200, description = "Plugin installed", body = PluginResponse),
|
|
(status = 400, description = "Bad request"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn install_plugin(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<InstallPluginRequest>,
|
|
) -> Result<Json<PluginResponse>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
let plugin_id =
|
|
plugin_manager
|
|
.install_plugin(&req.source)
|
|
.await
|
|
.map_err(|e| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
format!("Failed to install plugin: {e}"),
|
|
))
|
|
})?;
|
|
|
|
let plugin =
|
|
plugin_manager.get_plugin(&plugin_id).await.ok_or_else(|| {
|
|
ApiError(pinakes_core::error::PinakesError::NotFound(
|
|
"Plugin installed but not found".to_string(),
|
|
))
|
|
})?;
|
|
|
|
let enabled = plugin_manager.is_plugin_enabled(&plugin_id).await;
|
|
Ok(Json(PluginResponse::new(plugin, enabled)))
|
|
}
|
|
|
|
/// Uninstall a plugin
|
|
#[utoipa::path(
|
|
delete,
|
|
path = "/api/v1/plugins/{id}",
|
|
tag = "plugins",
|
|
params(("id" = String, Path, description = "Plugin ID")),
|
|
responses(
|
|
(status = 200, description = "Plugin uninstalled"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn uninstall_plugin(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
plugin_manager.uninstall_plugin(&id).await.map_err(|e| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
format!("Failed to uninstall plugin: {e}"),
|
|
))
|
|
})?;
|
|
|
|
Ok(Json(serde_json::json!({"uninstalled": true})))
|
|
}
|
|
|
|
/// Enable or disable a plugin
|
|
#[utoipa::path(
|
|
patch,
|
|
path = "/api/v1/plugins/{id}/toggle",
|
|
tag = "plugins",
|
|
params(("id" = String, Path, description = "Plugin ID")),
|
|
request_body = TogglePluginRequest,
|
|
responses(
|
|
(status = 200, description = "Plugin toggled"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn toggle_plugin(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
Json(req): Json<TogglePluginRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
if req.enabled {
|
|
plugin_manager.enable_plugin(&id).await.map_err(|e| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
format!("Failed to enable plugin: {e}"),
|
|
))
|
|
})?;
|
|
} else {
|
|
plugin_manager.disable_plugin(&id).await.map_err(|e| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
format!("Failed to disable plugin: {e}"),
|
|
))
|
|
})?;
|
|
}
|
|
|
|
// Re-discover capabilities after toggle so cached data stays current
|
|
if let Some(ref pipeline) = state.plugin_pipeline
|
|
&& let Err(e) = pipeline.discover_capabilities().await
|
|
{
|
|
tracing::warn!(
|
|
plugin_id = %id,
|
|
error = %e,
|
|
"failed to re-discover capabilities after plugin toggle"
|
|
);
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({
|
|
"id": id,
|
|
"enabled": req.enabled
|
|
})))
|
|
}
|
|
|
|
/// List all UI pages provided by loaded plugins
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/plugins/ui/pages",
|
|
tag = "plugins",
|
|
responses(
|
|
(status = 200, description = "Plugin UI pages", body = Vec<PluginUiPageEntry>),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn list_plugin_ui_pages(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<PluginUiPageEntry>>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
let pages = plugin_manager.list_ui_pages_with_endpoints().await;
|
|
let entries = pages
|
|
.into_iter()
|
|
.map(|(plugin_id, page, allowed_endpoints)| {
|
|
PluginUiPageEntry {
|
|
plugin_id,
|
|
page,
|
|
allowed_endpoints,
|
|
}
|
|
})
|
|
.collect();
|
|
Ok(Json(entries))
|
|
}
|
|
|
|
/// List all UI widgets provided by loaded plugins
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/plugins/ui/widgets",
|
|
tag = "plugins",
|
|
responses(
|
|
(status = 200, description = "Plugin UI widgets", body = Vec<PluginUiWidgetEntry>),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn list_plugin_ui_widgets(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<PluginUiWidgetEntry>>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
let widgets = plugin_manager.list_ui_widgets().await;
|
|
let entries = widgets
|
|
.into_iter()
|
|
.map(|(plugin_id, widget)| PluginUiWidgetEntry { plugin_id, widget })
|
|
.collect();
|
|
Ok(Json(entries))
|
|
}
|
|
|
|
/// Receive a plugin event emitted from the UI and dispatch it to interested
|
|
/// server-side event-handler plugins via the pipeline.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/plugins/events",
|
|
tag = "plugins",
|
|
request_body = PluginEventRequest,
|
|
responses(
|
|
(status = 200, description = "Event received"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn emit_plugin_event(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<PluginEventRequest>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
tracing::info!(event = %req.event, "plugin UI event received");
|
|
state.emit_plugin_event(&req.event, &req.payload);
|
|
Ok(Json(
|
|
serde_json::json!({ "received": true, "event": req.event }),
|
|
))
|
|
}
|
|
|
|
/// List merged CSS custom property overrides from all enabled plugins
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/plugins/ui/theme",
|
|
tag = "plugins",
|
|
responses(
|
|
(status = 200, description = "Plugin UI theme extensions"),
|
|
(status = 401, description = "Unauthorized"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn list_plugin_ui_theme_extensions(
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<FxHashMap<String, String>>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
Ok(Json(plugin_manager.list_ui_theme_extensions().await))
|
|
}
|
|
|
|
/// Reload a plugin (for development)
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/v1/plugins/{id}/reload",
|
|
tag = "plugins",
|
|
params(("id" = String, Path, description = "Plugin ID")),
|
|
responses(
|
|
(status = 200, description = "Plugin reloaded"),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 403, description = "Forbidden"),
|
|
(status = 404, description = "Not found"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn reload_plugin(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
let plugin_manager = require_plugin_manager(&state)?;
|
|
|
|
plugin_manager.reload_plugin(&id).await.map_err(|e| {
|
|
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
|
|
format!("Failed to reload plugin: {e}"),
|
|
))
|
|
})?;
|
|
|
|
// Re-discover capabilities after reload so cached data stays current
|
|
if let Some(ref pipeline) = state.plugin_pipeline
|
|
&& let Err(e) = pipeline.discover_capabilities().await
|
|
{
|
|
tracing::warn!(
|
|
plugin_id = %id,
|
|
error = %e,
|
|
"failed to re-discover capabilities after plugin reload"
|
|
);
|
|
}
|
|
|
|
Ok(Json(serde_json::json!({"reloaded": true})))
|
|
}
|