use actus::prelude::*;
use serde_json::{Value as JsonValue, json};
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
struct Tenants;
#[controller]
impl Tenants {
routes! {
GET "{tenant}" => home(params: &Params, tenant: String),
GET "{tenant}/orders/{id}" => order(tenant: String, id: u64),
POST "{tenant}/orders" => create(tenant: String, data: JsonValue),
}
pub async fn home(&self, params: &Params, tenant: String) -> Reply {
reply!(json!({ "at": "tenants", "tenant": tenant, "alias_prefix": params.alias_prefix() }))
}
pub async fn order(&self, tenant: String, id: u64) -> Reply {
reply!(json!({ "at": "tenants", "tenant": tenant, "order": id }))
}
pub async fn create(&self, tenant: String, data: JsonValue) -> Reply {
reply!(
status = StatusCode::CREATED,
json!({ "at": "tenants", "tenant": tenant, "created": data })
)
}
}
struct Assets;
#[controller]
impl Assets {
routes! {
GET "{...path}" => file(params: &Params, path: String),
}
pub async fn file(&self, params: &Params, path: String) -> Reply {
reply!(json!({ "at": "assets", "path": path, "alias_prefix": params.alias_prefix() }))
}
}
struct Api;
#[controller]
impl Api {
routes! {
GET "{...path}" => any(path: String),
}
pub async fn any(&self, path: String) -> Reply {
reply!(json!({ "at": "api", "path": path }))
}
}
struct Spa;
#[controller]
impl Spa {
routes! {
GET "{...path}" => page(path: String),
}
pub async fn page(&self, path: String) -> Reply {
reply!(json!({ "at": "spa", "path": path }))
}
}
app_routes! {
deps(tenant_host: Option<String>) {}
hosts {
"admin.example.test" => "api",
tenant_host => "tenants/{tenant}" shares ["assets"],
}
routes {
"tenants" => Tenants,
"assets" => Assets,
"api" => Api,
"*" => Spa,
}
}
type Seen = Arc<Mutex<Vec<(String, Option<&'static str>)>>>;
struct SeesPath {
router: Arc<Router>,
seen: Seen,
}
#[async_trait]
impl Middleware for SeesPath {
async fn before(&self, request: &mut Request) -> Result<Outcome, WebError> {
let matched = self
.router
.match_controller(&request.path_parts)
.map(|m| m.controller.__name());
self.seen
.lock()
.unwrap()
.push((request.path_parts.join("/"), matched));
Ok(Outcome::Continue)
}
}
async fn spawn(tenant_host: Option<&str>) -> (SocketAddr, Seen, oneshot::Sender<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen: Seen = Arc::default();
let (tx, rx) = oneshot::channel::<()>();
let tenant_host = tenant_host.map(String::from);
let log = Arc::clone(&seen);
tokio::spawn(async move {
let server = Server::new(init(tenant_host).await.unwrap());
let sees = SeesPath {
router: server.router(),
seen: log,
};
server
.with_middleware(sees)
.run_with_shutdown_listener(listener, async move {
let _ = rx.await;
})
.await
.unwrap();
});
(addr, seen, tx)
}
async fn http(addr: SocketAddr, raw: &str) -> (u16, Option<String>, JsonValue) {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream.write_all(raw.as_bytes()).await.unwrap();
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.unwrap();
let split = buf
.windows(4)
.position(|w| w == b"\r\n\r\n")
.expect("a complete response head");
let head = std::str::from_utf8(&buf[..split]).unwrap();
let mut lines = head.split("\r\n");
let status = lines
.next()
.unwrap()
.split_whitespace()
.nth(1)
.unwrap()
.parse()
.unwrap();
let allow = lines
.filter_map(|l| l.split_once(": "))
.find(|(n, _)| n.eq_ignore_ascii_case("allow"))
.map(|(_, v)| v.to_string());
let body = &buf[split + 4..];
let body = if body.is_empty() {
JsonValue::Null
} else {
serde_json::from_slice(body).unwrap()
};
(status, allow, body)
}
async fn get(addr: SocketAddr, host: &str, path: &str) -> (u16, JsonValue) {
let raw = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
let (status, _, body) = http(addr, &raw).await;
(status, body)
}
const TENANT_HOST: &str = "{tenant}.example.test";
#[tokio::test]
async fn an_aliased_host_is_the_aliased_mount_through_its_own_routes() {
let (addr, _, stop) = spawn(Some(TENANT_HOST)).await;
let host = "acme.example.test:8080";
assert_eq!(
get(addr, host, "/").await,
(
200,
json!({ "at": "tenants", "tenant": "acme", "alias_prefix": "tenants/acme" })
)
);
assert_eq!(
get(addr, host, "/orders/7").await,
(
200,
json!({ "at": "tenants", "tenant": "acme", "order": 7 })
)
);
let body = r#"{"sku":"x1"}"#;
let raw = format!(
"POST /orders HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let (status, _, created) = http(addr, &raw).await;
assert_eq!(status, 201);
assert_eq!(
created,
json!({ "at": "tenants", "tenant": "acme", "created": { "sku": "x1" } })
);
let raw = format!("DELETE /orders/7 HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
let (status, allow, _) = http(addr, &raw).await;
assert_eq!((status, allow.as_deref()), (405, Some("GET")));
let _ = stop.send(());
}
#[tokio::test]
async fn shared_mounts_keep_their_paths_and_nothing_else_is_reachable() {
let (addr, _, stop) = spawn(Some(TENANT_HOST)).await;
let host = "acme.example.test";
assert_eq!(
get(addr, host, "/assets/app.js").await,
(
200,
json!({ "at": "assets", "path": "app.js", "alias_prefix": null })
)
);
let (status, body) = get(addr, host, "/api/things").await;
assert_eq!(status, 404);
assert_ne!(body["at"], "api");
let (status, body) = get(addr, host, "/no/such/page").await;
assert_eq!(status, 404);
assert_ne!(body["at"], "spa");
let _ = stop.send(());
}
#[tokio::test]
async fn a_host_no_alias_names_is_routed_as_before() {
let (addr, _, stop) = spawn(Some(TENANT_HOST)).await;
for host in [
"127.0.0.1",
"localhost:8080",
"a.b.example.test",
"under_score.example.test",
] {
assert_eq!(
get(addr, host, "/api/x").await,
(200, json!({ "at": "api", "path": "x" })),
"{host}"
);
assert_eq!(
get(addr, host, "/somewhere").await,
(200, json!({ "at": "spa", "path": "somewhere" })),
"{host}"
);
}
assert_eq!(
get(addr, "127.0.0.1", "/tenants/acme").await,
(
200,
json!({ "at": "tenants", "tenant": "acme", "alias_prefix": null })
)
);
let _ = stop.send(());
}
#[tokio::test]
async fn middleware_sees_the_aliased_path_and_the_matcher_agrees_with_the_dispatch() {
let (addr, seen, stop) = spawn(Some(TENANT_HOST)).await;
get(addr, "acme.example.test", "/orders/7").await;
get(addr, "acme.example.test", "/assets/app.js").await;
get(addr, "127.0.0.1", "/api/x").await;
assert_eq!(
*seen.lock().unwrap(),
vec![
("tenants/acme/orders/7".to_string(), Some("Tenants")),
("assets/app.js".to_string(), Some("Assets")),
("api/x".to_string(), Some("Api")),
]
);
let _ = stop.send(());
}
#[tokio::test]
async fn host_matching_ignores_case_port_and_trailing_dot_and_reads_the_absolute_form() {
let (addr, _, stop) = spawn(Some(TENANT_HOST)).await;
assert_eq!(
get(addr, "ACME.Example.TEST.:8080", "/orders/1").await,
(
200,
json!({ "at": "tenants", "tenant": "acme", "order": 1 })
)
);
let raw = "GET http://globex.example.test/orders/3 HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
let (status, _, body) = http(addr, raw).await;
assert_eq!(
(status, body),
(
200,
json!({ "at": "tenants", "tenant": "globex", "order": 3 })
)
);
let _ = stop.send(());
}
#[tokio::test]
async fn the_first_alias_that_names_the_host_wins() {
let (addr, _, stop) = spawn(Some(TENANT_HOST)).await;
assert_eq!(
get(addr, "admin.example.test", "/stats").await,
(200, json!({ "at": "api", "path": "stats" }))
);
let _ = stop.send(());
}
#[tokio::test]
async fn an_unconfigured_alias_does_not_exist() {
let (addr, _, stop) = spawn(None).await;
assert_eq!(
get(addr, "acme.example.test", "/orders/7").await,
(200, json!({ "at": "spa", "path": "orders/7" }))
);
assert_eq!(
get(addr, "admin.example.test", "/stats").await,
(200, json!({ "at": "api", "path": "stats" }))
);
let _ = stop.send(());
}