use std::{env, net::SocketAddr}; use axum::{ Router, http::{HeaderMap, HeaderValue, header}, response::IntoResponse, routing::get, }; mod authz; mod i18n; mod pages; mod schema; mod services; mod ui; // The server's system column vocabulary, read out of `common` the same way the // generated protos are: this crate compiles that source tree directly instead // of depending on the crate. Only `is_system_column` is used here. #[path = "../../common/src/grpc_error.rs"] #[allow(dead_code)] mod grpc_error; #[path = "../../common/src/system_column.rs"] #[allow(dead_code)] mod system_column; mod analytics { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.analytics.rs" )); } mod auth { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.auth.rs" )); } mod definitions { #[allow(dead_code)] pub mod common { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.common.rs" )); } pub mod table_definition { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.table_definition.rs" )); } pub mod table_structure { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.table_structure.rs" )); } pub mod table_script { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.table_script.rs" )); } pub mod table_validation { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.table_validation.rs" )); } pub mod tables_data { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.tables_data.rs" )); } pub mod ecb { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.ecb.rs" )); } pub mod exchange_rates { include!(concat!( env!("CARGO_MANIFEST_DIR"), "/../common/src/proto/komp_ac.exchange_rates.rs" )); } } use auth::auth_service_client::AuthServiceClient; use definitions::{ ecb, exchange_rates::exchange_rate_service_client::ExchangeRateServiceClient, table_definition::table_definition_client::TableDefinitionClient, table_script::table_script_client::TableScriptClient, table_structure::table_structure_service_client::TableStructureServiceClient, table_validation::table_validation_service_client::TableValidationServiceClient, tables_data::tables_data_client::TablesDataClient, }; use tonic::transport::Channel; use analytics::analytics_service_client::AnalyticsServiceClient; use ecb::ecb_service_client::EcbServiceClient; const APP_CSS: &str = include_str!("../static/app.css"); /// The gRPC clients every handler shares. One lazily connected channel backs /// all of them. #[derive(Clone)] pub(crate) struct AppState { analytics: AnalyticsServiceClient, auth: AuthServiceClient, definitions: TableDefinitionClient, scripts: TableScriptClient, structures: TableStructureServiceClient, validations: TableValidationServiceClient, tables_data: TablesDataClient, ecb: EcbServiceClient, exchange_rates: ExchangeRateServiceClient, /// The imports this process is running, which the import page polls. Not a /// client: an import outlives the request that started it, so where it has /// got to has to live somewhere both the task and the next request can see. imports: pages::import_export::import::progress::ImportJobs, } /// Starts the web UI as a detached task on the current Tokio runtime. /// /// The web UI still uses the existing gRPC endpoints for now. Keeping startup /// here limits the server integration to a single call until the application /// is switched to the in-process analytics runtime. pub fn spawn() -> tokio::task::JoinHandle<()> { tokio::spawn(async { if let Err(error) = serve().await { eprintln!("Web UI stopped: {error}"); } }) } /// Serves the web UI until the task is cancelled or the listener fails. pub async fn serve() -> Result<(), Box> { let grpc_endpoint = env::var("ANALYTICS_GRPC_ENDPOINT").unwrap_or_else(|_| "http://[::1]:50051".into()); let listen_address = env::var("LISTEN_ADDRESS") .unwrap_or_else(|_| "127.0.0.1:3000".into()) .parse::()?; let channel = Channel::from_shared(grpc_endpoint.clone())?.connect_lazy(); let state = AppState { analytics: AnalyticsServiceClient::new(channel.clone()), auth: AuthServiceClient::new(channel.clone()), definitions: TableDefinitionClient::new(channel.clone()), scripts: TableScriptClient::new(channel.clone()), validations: TableValidationServiceClient::new(channel.clone()), tables_data: TablesDataClient::new(channel.clone()), ecb: EcbServiceClient::new(channel.clone()), exchange_rates: ExchangeRateServiceClient::new(channel.clone()), structures: TableStructureServiceClient::new(channel), imports: Default::default(), }; let listener = tokio::net::TcpListener::bind(listen_address).await?; println!("Web UI: http://{listen_address}"); println!("Analytics gRPC endpoint: {grpc_endpoint}"); axum::serve(listener, router(state)).await?; Ok(()) } /// Every route in the site. One `router()` per page module, each of which /// documents the templates its endpoints render. fn router(state: AppState) -> Router { Router::new() .route("/static/app.css", get(stylesheet)) .merge(pages::analytics::router()) .merge(pages::login::router()) .merge(pages::register::router()) .merge(pages::admin::admin::router()) .merge(pages::permissions::router()) .merge(pages::admin::table_definition::router()) .merge(pages::admin::ecb::router()) .merge(pages::add_table::router()) .merge(pages::add_logic::router()) .merge(pages::add_validation::router()) .merge(pages::import_export::router()) .with_state(state) } /// The one stylesheet every page links, so no template inlines CSS. async fn stylesheet() -> impl IntoResponse { ( [(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))], APP_CSS, ) } pub(crate) fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get(header::COOKIE)? .to_str() .ok()? .split(';') .map(str::trim) .find_map(|cookie| cookie.strip_prefix(name)?.strip_prefix('=')) } #[cfg(test)] mod tests { use super::*; use axum::{body::Body, http::Request}; use tower::ServiceExt; /// The channel is lazy, so routes that do not call gRPC serve fine /// without a backend. fn test_router() -> Router { let channel = Channel::from_static("http://[::1]:50051").connect_lazy(); router(AppState { analytics: AnalyticsServiceClient::new(channel.clone()), auth: AuthServiceClient::new(channel.clone()), definitions: TableDefinitionClient::new(channel.clone()), scripts: TableScriptClient::new(channel.clone()), validations: TableValidationServiceClient::new(channel.clone()), tables_data: TablesDataClient::new(channel.clone()), ecb: EcbServiceClient::new(channel.clone()), exchange_rates: ExchangeRateServiceClient::new(channel.clone()), structures: TableStructureServiceClient::new(channel), imports: Default::default(), }) } async fn get(path: &str) -> (axum::http::StatusCode, String) { let response = test_router() .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) .await .unwrap(); let status = response.status(); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); (status, String::from_utf8(body.to_vec()).unwrap()) } #[tokio::test] async fn every_backend_free_page_renders_the_shared_shell() { for path in ["/", "/login", "/register"] { let (status, body) = get(path).await; assert!(status.is_success(), "{path} returned {status}"); // Penguin UI's navbar, so what identifies it is its Alpine // state rather than a class this app wrote. assert!( body.contains(r#"x-data="{ mobileMenuIsOpen: false }""#), "{path} is missing the shared navbar" ); assert!( body.contains("href=\"/static/app.css\""), "{path} is missing the shared stylesheet" ); // The shell carries exactly two