use crate::error::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "placement", rename_all = "kebab-case")]
pub enum NavigationPlacement {
TopLevel,
Nested {
#[serde(default)]
path: Vec<String>,
},
Hidden,
}
impl Default for NavigationPlacement {
fn default() -> Self {
NavigationPlacement::Nested {
path: vec!["Apps".to_string()],
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NavigationConfig {
#[serde(flatten)]
pub placement: NavigationPlacement,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl NavigationConfig {
pub fn top_level() -> Self {
Self {
placement: NavigationPlacement::TopLevel,
..Default::default()
}
}
pub fn nested(path: &[&str]) -> Self {
Self {
placement: NavigationPlacement::Nested {
path: path.iter().map(|s| s.to_string()).collect(),
},
..Default::default()
}
}
pub fn hidden() -> Self {
Self {
placement: NavigationPlacement::Hidden,
..Default::default()
}
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn order(mut self, order: u32) -> Self {
self.order = Some(order);
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppManifest {
pub name: String,
pub entry_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub routes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub navigation: Option<NavigationConfig>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub api_access: bool,
}
impl AppManifest {
pub fn new(name: impl Into<String>, entry_path: impl Into<String>) -> Self {
Self {
name: name.into(),
entry_path: entry_path.into(),
description: None,
icon: None,
routes: None,
version: None,
navigation: None,
api_access: false,
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn routes(mut self, routes: &[&str]) -> Self {
self.routes = Some(routes.iter().map(|s| s.to_string()).collect());
self
}
pub fn navigation(mut self, nav: NavigationConfig) -> Self {
self.navigation = Some(nav);
self
}
pub fn api_access(mut self) -> Self {
self.api_access = true;
self
}
pub fn validate(&self) -> Result<()> {
use crate::error::ConnectorError;
if self.name.is_empty() {
return Err(ConnectorError::InvalidConfig(
"App manifest must have a non-empty 'name' field".to_string(),
));
}
if self.entry_path.is_empty() {
return Err(ConnectorError::InvalidConfig(
"App manifest must have a non-empty 'entry_path' field".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BodyEncoding {
#[default]
Utf8,
Base64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppPageRequest {
pub path: String,
#[serde(default)]
pub params: HashMap<String, String>,
}
impl AppPageRequest {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
params: HashMap::new(),
}
}
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.params.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppPageResponse {
pub content_type: String,
pub body: String,
#[serde(default = "default_status")]
pub status: u16,
#[serde(default)]
pub encoding: BodyEncoding,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
}
fn default_status() -> u16 {
200
}
impl AppPageResponse {
pub fn html(body: impl Into<String>) -> Self {
Self {
content_type: "text/html".to_string(),
body: body.into(),
status: 200,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn json(body: impl Into<String>) -> Self {
Self {
content_type: "application/json".to_string(),
body: body.into(),
status: 200,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn css(body: impl Into<String>) -> Self {
Self {
content_type: "text/css".to_string(),
body: body.into(),
status: 200,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn javascript(body: impl Into<String>) -> Self {
Self {
content_type: "application/javascript".to_string(),
body: body.into(),
status: 200,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn not_found() -> Self {
Self {
content_type: "text/html".to_string(),
body: "<h1>404 - Not Found</h1>".to_string(),
status: 404,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn not_found_with(body: impl Into<String>) -> Self {
Self {
content_type: "text/html".to_string(),
body: body.into(),
status: 404,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn error(status: u16, message: impl Into<String>) -> Self {
Self {
content_type: "text/html".to_string(),
body: format!("<h1>Error {status}</h1><p>{}</p>", message.into()),
status,
encoding: BodyEncoding::Utf8,
headers: HashMap::new(),
}
}
pub fn binary(content_type: impl Into<String>, base64_body: impl Into<String>) -> Self {
Self {
content_type: content_type.into(),
body: base64_body.into(),
status: 200,
encoding: BodyEncoding::Base64,
headers: HashMap::new(),
}
}
pub fn status(mut self, status: u16) -> Self {
self.status = status;
self
}
pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(key.into(), value.into());
self
}
}
use std::sync::LazyLock;
static BINARY_EXTENSIONS: LazyLock<std::collections::HashSet<&'static str>> = LazyLock::new(|| {
[
"png", "jpg", "jpeg", "gif", "webp", "ico", "bmp", "woff", "woff2", "ttf", "eot", "otf",
"pdf", "zip", "tar", "gz", "mp3", "mp4", "webm", "ogg", "wav",
]
.into_iter()
.collect()
});
static CONTENT_TYPE_MAP: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
[
("html", "text/html"),
("htm", "text/html"),
("css", "text/css"),
("js", "application/javascript"),
("mjs", "application/javascript"),
("json", "application/json"),
("xml", "application/xml"),
("txt", "text/plain"),
("md", "text/markdown"),
("csv", "text/csv"),
("svg", "image/svg+xml"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("ico", "image/x-icon"),
("bmp", "image/bmp"),
("woff", "font/woff"),
("woff2", "font/woff2"),
("ttf", "font/ttf"),
("eot", "application/vnd.ms-fontobject"),
("otf", "font/otf"),
("pdf", "application/pdf"),
("zip", "application/zip"),
("mp3", "audio/mpeg"),
("mp4", "video/mp4"),
("webm", "video/webm"),
("ogg", "audio/ogg"),
("wav", "audio/wav"),
]
.into_iter()
.collect()
});
#[derive(Debug, Clone, Default)]
pub struct StaticFileConfig {
pub static_dir: Option<PathBuf>,
resolved_dir: Option<PathBuf>,
}
impl StaticFileConfig {
pub fn new(static_dir: impl Into<PathBuf>) -> Self {
let static_dir: PathBuf = static_dir.into();
let resolved_dir = std::fs::canonicalize(&static_dir).ok();
Self {
static_dir: Some(static_dir),
resolved_dir,
}
}
pub fn is_enabled(&self) -> bool {
self.resolved_dir.is_some()
}
pub fn try_serve(&self, request_path: &str) -> Option<AppPageResponse> {
let resolved_dir = self.resolved_dir.as_ref()?;
let relative_path = request_path.trim_start_matches('/');
if relative_path.contains("..") || relative_path.contains('\\') {
return None;
}
let file_path = resolved_dir.join(relative_path);
let canonical = std::fs::canonicalize(&file_path).ok()?;
if !canonical.starts_with(resolved_dir) {
return None;
}
let metadata = std::fs::metadata(&canonical).ok()?;
if !metadata.is_file() {
return None;
}
let ext = file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let content_type = CONTENT_TYPE_MAP
.get(ext.as_str())
.copied()
.unwrap_or("application/octet-stream");
let is_binary = BINARY_EXTENSIONS.contains(ext.as_str());
let (body, encoding) = if is_binary {
let bytes = std::fs::read(&canonical).ok()?;
use base64::{Engine as _, engine::general_purpose::STANDARD};
(STANDARD.encode(&bytes), BodyEncoding::Base64)
} else {
let content = std::fs::read_to_string(&canonical).ok()?;
(content, BodyEncoding::Utf8)
};
Some(AppPageResponse {
content_type: content_type.to_string(),
body,
status: 200,
encoding,
headers: HashMap::new(),
})
}
}
#[async_trait]
pub trait AppConnector: Send + Sync {
fn app_manifest(&self) -> AppManifest;
fn app_manifests(&self) -> Vec<AppManifest> {
vec![self.app_manifest()]
}
fn static_file_config(&self) -> Option<&StaticFileConfig> {
None
}
async fn render_page(&self, request: AppPageRequest) -> AppPageResponse;
async fn execute(&self, request: AppPageRequest) -> AppPageResponse {
if let Some(config) = self.static_file_config()
&& let Some(response) = config.try_serve(&request.path)
{
return response;
}
self.render_page(request).await
}
fn app_metadata(&self) -> HashMap<String, String> {
let manifests = self.app_manifests();
if manifests.len() > 1 {
let manifests_json = serde_json::to_string(&manifests).unwrap_or_default();
let mut meta = HashMap::new();
meta.insert("app_manifests".to_string(), manifests_json);
meta
} else {
let manifest = self.app_manifest();
let manifest_json = serde_json::to_string(&manifest).unwrap_or_default();
let mut meta = HashMap::new();
meta.insert("app_manifest".to_string(), manifest_json);
meta
}
}
fn timeout_ms(&self) -> u64 {
10000
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_navigation_config_top_level() {
let nav = NavigationConfig::top_level().icon("hero-home").order(10);
assert!(matches!(nav.placement, NavigationPlacement::TopLevel));
assert_eq!(nav.icon, Some("hero-home".to_string()));
assert_eq!(nav.order, Some(10));
}
#[test]
fn test_navigation_config_nested() {
let nav = NavigationConfig::nested(&["Security", "SOC"]);
match nav.placement {
NavigationPlacement::Nested { path } => {
assert_eq!(path, vec!["Security", "SOC"]);
}
_ => panic!("Expected nested placement"),
}
}
#[test]
fn test_navigation_config_hidden() {
let nav = NavigationConfig::hidden();
assert!(matches!(nav.placement, NavigationPlacement::Hidden));
}
#[test]
fn test_app_manifest_builder() {
let manifest = AppManifest::new("Test App", "/")
.description("A test app")
.icon("hero-beaker")
.version("1.0.0")
.routes(&["/", "/about"]);
assert_eq!(manifest.name, "Test App");
assert_eq!(manifest.entry_path, "/");
assert_eq!(manifest.description, Some("A test app".to_string()));
assert_eq!(manifest.icon, Some("hero-beaker".to_string()));
assert_eq!(manifest.version, Some("1.0.0".to_string()));
assert_eq!(
manifest.routes,
Some(vec!["/".to_string(), "/about".to_string()])
);
}
#[test]
fn test_app_manifest_validate() {
let valid = AppManifest::new("Test", "/");
assert!(valid.validate().is_ok());
let empty_name = AppManifest::new("", "/");
assert!(empty_name.validate().is_err());
let empty_path = AppManifest::new("Test", "");
assert!(empty_path.validate().is_err());
}
#[test]
fn test_app_page_response_html() {
let resp = AppPageResponse::html("<h1>Hello</h1>");
assert_eq!(resp.content_type, "text/html");
assert_eq!(resp.body, "<h1>Hello</h1>");
assert_eq!(resp.status, 200);
assert_eq!(resp.encoding, BodyEncoding::Utf8);
}
#[test]
fn test_app_page_response_not_found() {
let resp = AppPageResponse::not_found();
assert_eq!(resp.status, 404);
}
#[test]
fn test_app_page_response_binary() {
let resp = AppPageResponse::binary("image/png", "base64data==");
assert_eq!(resp.content_type, "image/png");
assert_eq!(resp.encoding, BodyEncoding::Base64);
}
#[test]
fn test_navigation_serialization() {
let nav = NavigationConfig::nested(&["Apps"]).icon("hero-cog");
let json = serde_json::to_string(&nav).unwrap();
assert!(json.contains("nested"));
assert!(json.contains("Apps"));
assert!(json.contains("hero-cog"));
}
#[test]
fn test_app_manifest_serialization() {
let manifest = AppManifest::new("Test", "/").navigation(NavigationConfig::top_level());
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains("Test"));
assert!(json.contains("top-level"));
}
}