Files
komp_ac/web/src/lib.rs
2026-08-04 17:20:58 +02:00

285 lines
9.9 KiB
Rust

use std::{env, net::SocketAddr};
use axum::{
Router,
http::{HeaderMap, HeaderValue, header},
response::IntoResponse,
routing::get,
};
mod pages;
mod schema;
mod services;
mod ui;
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"
));
}
}
use auth::auth_service_client::AuthServiceClient;
use definitions::{
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;
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<Channel>,
auth: AuthServiceClient<Channel>,
definitions: TableDefinitionClient<Channel>,
scripts: TableScriptClient<Channel>,
structures: TableStructureServiceClient<Channel>,
validations: TableValidationServiceClient<Channel>,
tables_data: TablesDataClient<Channel>,
}
/// 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<dyn std::error::Error + Send + Sync>> {
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::<SocketAddr>()?;
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()),
structures: TableStructureServiceClient::new(channel),
};
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::admin::admin::router())
.merge(pages::admin::table_definition::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()),
structures: TableStructureServiceClient::new(channel),
})
}
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"] {
let (status, body) = get(path).await;
assert!(status.is_success(), "{path} returned {status}");
assert!(
body.contains("<header class=\"topbar\">"),
"{path} is missing the shared navbar"
);
assert!(
body.contains("href=\"/static/app.css\""),
"{path} is missing the shared stylesheet"
);
// The shell carries exactly two <style> elements, and neither is
// page styling: the cascade-order declaration that puts app.css
// between Tailwind's preflight and its utilities, and Penguin UI's
// @theme. Any third one is a page inlining CSS instead of
// linking the stylesheet.
assert_eq!(
body.matches("<style").count(),
2,
"{path} inlines CSS instead of linking the stylesheet"
);
assert!(
body.contains("@layer theme, base, app, components, utilities;"),
"{path} lost the cascade order app.css depends on"
);
}
}
#[tokio::test]
async fn signed_out_navbar_offers_login_instead_of_logout() {
let (_, body) = get("/").await;
assert!(body.contains("href=\"/login\""));
assert!(!body.contains("hx-post=\"/logout\""));
}
/// Every table-definition endpoint is mounted, and every one of them is
/// behind a session: without a cookie there is no request to sign, so each
/// answers with the redirect to the login page rather than a 404 or a call
/// to the backend.
#[tokio::test]
async fn the_table_definition_workspace_is_mounted_and_needs_a_session() {
for path in ["/admin/table-definition", "/admin/table-definition/workspace"] {
let (status, _) = get(path).await;
assert_eq!(
status,
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
for path in [
"/admin/table-definition/columns",
"/admin/table-definition/columns/builder",
"/admin/table-definition/rename",
"/admin/table-definition/delete",
"/admin/table-definition/copy",
"/admin/table-definition/invoice-template",
] {
let response = test_router()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("profile=billing&table=invoice"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
axum::http::StatusCode::SEE_OTHER,
"{path} did not send an anonymous visitor to the login page"
);
}
}
#[tokio::test]
async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await;
assert!(status.is_success());
assert!(body.contains(".topbar"));
}
}