use super::app::{
AppConnector, AppManifest, AppPageRequest, AppPageResponse, NavigationConfig, StaticFileConfig,
};
use crate::connector::{ConnectorConfig, ConnectorRunner};
use crate::error::{ConnectorError, Result};
use crate::types::{ConnectorBehavior, PayloadEncoding};
use async_trait::async_trait;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
pub struct App {
manifest: AppManifest,
static_config: Option<StaticFileConfig>,
handler: Option<HandlerFn>,
connector_config: ConnectorConfig,
#[allow(dead_code)]
local_port: Option<u16>,
}
type HandlerFn = Arc<
dyn Fn(AppPageRequest) -> Pin<Box<dyn Future<Output = AppPageResponse> + Send>> + Send + Sync,
>;
impl App {
pub fn builder() -> AppBuilder {
AppBuilder::default()
}
pub fn static_files(dir: impl Into<PathBuf>) -> AppBuilder {
let static_dir = dir.into();
AppBuilder {
static_dir: Some(static_dir),
serve_index_for_spa: true,
..Default::default()
}
}
pub fn from_env() -> Result<AppBuilder> {
let host = std::env::var("STRIKE48_HOST").map_err(|_| {
ConnectorError::InvalidConfig("STRIKE48_HOST environment variable not set".to_string())
})?;
let tenant_id = std::env::var("TENANT_ID").map_err(|_| {
ConnectorError::InvalidConfig("TENANT_ID environment variable not set".to_string())
})?;
let display_name = std::env::var("APP_NAME").ok();
let static_dir = std::env::var("APP_STATIC_DIR").ok().map(PathBuf::from);
let local_port = std::env::var("APP_LOCAL_PORT")
.ok()
.and_then(|p| p.parse().ok());
Ok(AppBuilder {
host: Some(host),
tenant_id: Some(tenant_id),
display_name,
static_dir,
local_port,
..Default::default()
})
}
pub async fn run(self) -> Result<()> {
let connector = BuiltAppConnector {
manifest: self.manifest,
static_config: self.static_config,
handler: self.handler,
connector_type: self.connector_config.connector_type.clone(),
};
let runner = ConnectorRunner::new(self.connector_config, Arc::new(connector));
runner.run().await
}
}
#[derive(Default)]
pub struct AppBuilder {
display_name: Option<String>,
entry_path: Option<String>,
description: Option<String>,
icon: Option<String>,
version: Option<String>,
routes: Option<Vec<String>>,
navigation: Option<NavigationConfig>,
api_access: bool,
static_dir: Option<PathBuf>,
#[allow(dead_code)]
serve_index_for_spa: bool,
handler: Option<HandlerFn>,
host: Option<String>,
tenant_id: Option<String>,
instance_id: Option<String>,
connector_name: Option<String>,
use_tls: Option<bool>,
local_port: Option<u16>,
}
impl AppBuilder {
pub fn display_name(mut self, name: impl Into<String>) -> Self {
self.display_name = Some(name.into());
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.display_name = Some(name.into());
self
}
pub fn entry_path(mut self, path: impl Into<String>) -> Self {
self.entry_path = Some(path.into());
self
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.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 static_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.static_dir = Some(dir.into());
self
}
pub fn handler<F, Fut>(mut self, f: F) -> Self
where
F: Fn(AppPageRequest) -> Fut + Send + Sync + 'static,
Fut: Future<Output = AppPageResponse> + Send + 'static,
{
self.handler = Some(Arc::new(move |req| Box::pin(f(req))));
self
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
pub fn tenant_id(mut self, id: impl Into<String>) -> Self {
self.tenant_id = Some(id.into());
self
}
pub fn instance_id(mut self, id: impl Into<String>) -> Self {
self.instance_id = Some(id.into());
self
}
pub fn connector_name(mut self, name: impl Into<String>) -> Self {
self.connector_name = Some(name.into());
self
}
pub fn use_tls(mut self, enabled: bool) -> Self {
self.use_tls = Some(enabled);
self
}
pub fn local_port(mut self, port: u16) -> Self {
self.local_port = Some(port);
self
}
pub fn build(self) -> Result<App> {
let display_name = std::env::var("APP_NAME")
.ok()
.filter(|s| !s.is_empty())
.or(self.display_name)
.ok_or_else(|| {
ConnectorError::InvalidConfig("App display_name is required".to_string())
})?;
let entry_path = self.entry_path.unwrap_or_else(|| "/".to_string());
let mut manifest = AppManifest::new(&display_name, &entry_path);
manifest.description = self.description;
manifest.icon = self.icon;
manifest.version = self.version;
manifest.routes = self.routes;
manifest.navigation = self.navigation;
manifest.api_access = self.api_access;
let static_config = self.static_dir.map(StaticFileConfig::new);
let env_config = ConnectorConfig::from_env();
let (resolved_host, use_tls, transport_type) = if let Some(explicit_host) = self.host {
match crate::url_parser::parse_url(&explicit_host) {
Ok(parsed) => (
parsed.host_port(),
self.use_tls.unwrap_or(parsed.use_tls),
parsed.transport,
),
Err(_) => (
explicit_host,
self.use_tls.unwrap_or(env_config.use_tls),
env_config.transport_type,
),
}
} else {
(
env_config.host,
self.use_tls.unwrap_or(env_config.use_tls),
env_config.transport_type,
)
};
let tenant_id = self.tenant_id.unwrap_or(env_config.tenant_id);
let instance_id = self.instance_id.unwrap_or_else(|| {
let env_instance = std::env::var("INSTANCE_ID").unwrap_or_default();
if !env_instance.is_empty() {
env_instance
} else {
format!(
"{}-{}",
display_name.to_lowercase().replace(' ', "-"),
chrono::Utc::now().timestamp_millis()
)
}
});
let connector_name = self
.connector_name
.unwrap_or_else(|| format!("app-{}", display_name.to_lowercase().replace(' ', "-")));
let connector_config = ConnectorConfig {
host: resolved_host,
tenant_id,
connector_type: connector_name,
instance_id,
version: manifest
.version
.clone()
.unwrap_or_else(|| "1.0.0".to_string()),
auth_token: env_config.auth_token,
use_tls,
transport_type,
max_concurrent_requests: 100,
reconnect_enabled: true,
reconnect_delay_ms: 500,
max_backoff_delay_ms: 60000,
reconnect_jitter_ms: 500,
display_name: Some(display_name.clone()),
tags: Vec::new(),
metadata: std::collections::HashMap::new(),
metrics_enabled: env_config.metrics_enabled,
metrics_interval_ms: env_config.metrics_interval_ms,
heartbeat_interval: None,
heartbeat_timeout: None,
};
Ok(App {
manifest,
static_config,
handler: self.handler,
connector_config,
local_port: self.local_port,
})
}
pub async fn run(self) -> Result<()> {
self.build()?.run().await
}
}
struct BuiltAppConnector {
manifest: AppManifest,
static_config: Option<StaticFileConfig>,
handler: Option<HandlerFn>,
connector_type: String,
}
impl crate::connector::BaseConnector for BuiltAppConnector {
fn connector_type(&self) -> &str {
&self.connector_type
}
fn version(&self) -> &str {
self.manifest.version.as_deref().unwrap_or("1.0.0")
}
fn behavior(&self) -> ConnectorBehavior {
ConnectorBehavior::App
}
fn supported_encodings(&self) -> Vec<PayloadEncoding> {
vec![PayloadEncoding::Json]
}
fn metadata(&self) -> HashMap<String, String> {
let manifest_json = serde_json::to_string(&self.manifest).unwrap_or_default();
let mut meta = HashMap::new();
meta.insert("app_manifest".to_string(), manifest_json);
meta.insert("timeout_ms".to_string(), "10000".to_string());
meta
}
fn execute(
&self,
request: serde_json::Value,
_capability_id: Option<&str>,
) -> Pin<Box<dyn Future<Output = Result<serde_json::Value>> + Send + '_>> {
Box::pin(async move {
let page_request: AppPageRequest =
serde_json::from_value(request).unwrap_or_else(|_| AppPageRequest {
path: "/".to_string(),
params: HashMap::new(),
});
if let Some(ref static_config) = self.static_config
&& let Some(response) = static_config.try_serve(&page_request.path)
{
return Ok(serde_json::to_value(response)?);
}
if let Some(ref handler) = self.handler {
let response = handler(page_request).await;
return Ok(serde_json::to_value(response)?);
}
Ok(serde_json::to_value(AppPageResponse::not_found())?)
})
}
}
#[async_trait]
impl AppConnector for BuiltAppConnector {
fn app_manifest(&self) -> AppManifest {
self.manifest.clone()
}
fn static_file_config(&self) -> Option<&StaticFileConfig> {
self.static_config.as_ref()
}
async fn render_page(&self, request: AppPageRequest) -> AppPageResponse {
if let Some(ref handler) = self.handler {
handler(request).await
} else {
AppPageResponse::not_found()
}
}
}
#[macro_export]
macro_rules! serve_app {
($dir:expr) => {
$crate::behaviors::serve::App::static_files($dir)
.display_name(env!("CARGO_PKG_NAME"))
.run()
};
($dir:expr, name = $name:expr) => {
$crate::behaviors::serve::App::static_files($dir)
.display_name($name)
.run()
};
($dir:expr, name = $name:expr, icon = $icon:expr) => {
$crate::behaviors::serve::App::static_files($dir)
.display_name($name)
.icon($icon)
.run()
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_basic() {
let app = App::builder()
.display_name("Test App")
.entry_path("/")
.build();
assert!(app.is_ok());
let app = app.unwrap();
assert_eq!(app.manifest.name, "Test App");
assert_eq!(app.manifest.entry_path, "/");
assert_eq!(app.connector_config.connector_type, "app-test-app");
}
#[test]
fn test_builder_requires_name() {
let result = App::builder().build();
assert!(result.is_err());
}
#[test]
fn test_builder_with_static_files() {
let app = App::static_files("./test")
.display_name("Static App")
.build();
assert!(app.is_ok());
let app = app.unwrap();
assert!(app.static_config.is_some());
}
#[test]
fn test_builder_with_connector_name() {
let app = App::builder()
.display_name("My App")
.connector_name("app-my-app-prod-host1")
.build();
assert!(app.is_ok());
let app = app.unwrap();
assert_eq!(app.manifest.name, "My App");
assert_eq!(app.connector_config.connector_type, "app-my-app-prod-host1");
}
#[test]
fn test_builder_name_alias() {
let app = App::builder().name("Legacy Name").build();
assert!(app.is_ok());
let app = app.unwrap();
assert_eq!(app.manifest.name, "Legacy Name");
}
#[test]
fn test_builder_with_options() {
let app = App::builder()
.display_name("Full App")
.description("A fully configured app")
.icon("hero-star")
.version("2.0.0")
.navigation(NavigationConfig::top_level())
.host("example.com:443")
.tenant_id("my-tenant")
.use_tls(true)
.build();
assert!(app.is_ok());
let app = app.unwrap();
assert_eq!(app.manifest.name, "Full App");
assert_eq!(
app.manifest.description,
Some("A fully configured app".to_string())
);
assert_eq!(app.manifest.icon, Some("hero-star".to_string()));
assert_eq!(app.manifest.version, Some("2.0.0".to_string()));
assert!(app.manifest.navigation.is_some());
}
}