285 lines
9.9 KiB
Rust
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"));
|
|
}
|
|
}
|