crabidy/crabidy-server/src/web.rs

134 lines
4.6 KiB
Rust

//! Serves the embedded web client (feature `web-ui`,
//! architecture/web-client.md): the trunk bundle staged by `build.rs`
//! ships inside the binary and answers every request the gRPC route
//! did not claim. Assets are public by design — the app shell is a
//! login page at worst; every RPC behind it stays gated by the auth
//! layer.
use axum::body::Body;
use axum::response::Response;
use http::{header, HeaderValue, Method, Request, StatusCode, Uri};
use include_dir::{include_dir, Dir};
/// The staged trunk bundle (or the build.rs placeholder page).
static DIST: Dir<'_> = include_dir!("$OUT_DIR/webdist");
/// Content type by file extension. The bundle is fully known at build
/// time, so an unknown extension is a programmer omission — served as
/// octet-stream rather than panicking.
fn content_type(path: &str) -> &'static str {
match path.rsplit_once('.').map(|(_, ext)| ext) {
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("wasm") => "application/wasm",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("ico") => "image/x-icon",
Some("txt") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
/// The asset for `uri`, falling back to `index.html` for pathless GETs
/// (the app owns its own view state; deep links reload the shell).
fn lookup(uri: &Uri) -> (&'static str, &'static [u8]) {
let path = uri.path().trim_start_matches('/');
let file = if path.is_empty() {
None
} else {
DIST.get_file(path)
};
match file {
Some(file) => (content_type(path), file.contents()),
None => (
"text/html; charset=utf-8",
DIST.get_file("index.html")
.map(include_dir::File::contents)
// The build script always stages an index.html; an empty
// page is the harmless fallback if it ever did not.
.unwrap_or(b""),
),
}
}
/// The fallback handler: serves bundle assets for GET/HEAD, 404s
/// everything else (non-GET traffic belongs to the gRPC route).
pub async fn serve_asset(request: Request<Body>) -> Response {
if request.method() != Method::GET && request.method() != Method::HEAD {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("static response");
}
let (content_type, bytes) = lookup(request.uri());
let body = if request.method() == Method::HEAD {
Body::empty()
} else {
Body::from(bytes)
};
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, HeaderValue::from_static(content_type))
.body(body)
.expect("static response")
}
#[cfg(test)]
mod tests {
use super::*;
fn get(path: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(path)
.body(Body::empty())
.expect("request")
}
async fn body_string(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
String::from_utf8_lossy(&bytes).into_owned()
}
#[tokio::test]
async fn the_root_serves_the_app_shell() {
let response = serve_asset(get("/")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
let html = body_string(response).await;
assert!(html.contains("crabidy"), "app shell served");
}
#[tokio::test]
async fn unknown_paths_fall_back_to_the_shell_get_only() {
let response = serve_asset(get("/some/deep/link")).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
let post = Request::builder()
.method(Method::POST)
.uri("/not-grpc")
.body(Body::empty())
.expect("request");
let response = serve_asset(post).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn content_types_cover_the_bundle() {
assert_eq!(content_type("a.wasm"), "application/wasm");
assert_eq!(content_type("a.js"), "application/javascript");
assert_eq!(content_type("a.css"), "text/css");
assert_eq!(content_type("weird.bin"), "application/octet-stream");
}
}