pinakes/packages/pinakes-server/src/routes/plugins.rs
NotAShelf 047801a9da
pinakes-server: import directly from extracted crates
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id43ab8edfd56196d376d72ecc136f6086a6a6964
2026-05-24 14:25:57 +03:00

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})))
}