#![cfg(feature = "serve")]
use faucet_cli::cli::ServeArgs;
use faucet_cli::serve::ServeConfig;
use std::collections::BTreeSet;
use std::time::Duration;
const ROUTES_BASE: &[(&str, &str)] = &[
("POST", "/v1/runs"),
("GET", "/v1/runs"),
("GET", "/v1/runs/{id}"),
("DELETE", "/v1/runs/{id}"),
("POST", "/v1/runs/{id}/cancel"),
("GET", "/v1/runs/{id}/logs"),
("GET", "/v1/schemas"),
("GET", "/v1/schemas/{kind}/{name}"),
("POST", "/v1/doctor"),
("POST", "/v1/backfill"),
("POST", "/v1/dlq/inspect"),
("POST", "/v1/dlq/replay"),
("POST", "/v1/dlq/discard"),
("GET", "/v1/audit"),
("POST", "/v1/reload"),
("GET", "/healthz"),
("GET", "/readyz"),
("GET", "/metrics"),
];
#[cfg(feature = "triggers")]
const ROUTES_TRIGGERS: &[(&str, &str)] = &[
("POST", "/v1/triggers/{name}"),
("PUT", "/v1/triggers/{name}"),
];
#[cfg(feature = "catalog")]
const ROUTES_CATALOG: &[(&str, &str)] = &[
("GET", "/v1/catalog/datasets"),
("GET", "/v1/catalog/datasets/{id}"),
("GET", "/v1/catalog/lineage"),
];
fn canonical_routes() -> BTreeSet<(String, String)> {
#[allow(unused_mut)]
let mut set: BTreeSet<(String, String)> = ROUTES_BASE
.iter()
.map(|(m, p)| (m.to_string(), p.to_string()))
.collect();
#[cfg(feature = "triggers")]
for (m, p) in ROUTES_TRIGGERS {
set.insert((m.to_string(), p.to_string()));
}
#[cfg(feature = "catalog")]
for (m, p) in ROUTES_CATALOG {
set.insert((m.to_string(), p.to_string()));
}
set
}
fn openapi_routes() -> BTreeSet<(String, String)> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../docs/openapi.yaml");
let text = std::fs::read_to_string(path).expect("read docs/openapi.yaml");
let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("parse openapi.yaml");
let methods = ["get", "post", "put", "delete", "patch", "head", "options"];
let mut set = BTreeSet::new();
let paths = doc["paths"].as_mapping().expect("paths mapping");
for (path, ops) in paths {
let path = path.as_str().unwrap().to_string();
#[cfg(not(feature = "triggers"))]
if path.starts_with("/v1/triggers") {
continue;
}
#[cfg(not(feature = "catalog"))]
if path.starts_with("/v1/catalog") {
continue;
}
let ops = ops.as_mapping().unwrap();
for (method, _) in ops {
let m = method.as_str().unwrap().to_ascii_lowercase();
if methods.contains(&m.as_str()) {
set.insert((m.to_ascii_uppercase(), path.clone()));
}
}
}
set
}
#[test]
fn openapi_paths_match_canonical_routes() {
let documented = openapi_routes();
let canonical = canonical_routes();
let undocumented: Vec<_> = canonical.difference(&documented).collect();
let unrouted: Vec<_> = documented.difference(&canonical).collect();
assert!(
undocumented.is_empty(),
"routes missing from docs/openapi.yaml: {undocumented:?}"
);
assert!(
unrouted.is_empty(),
"openapi.yaml documents paths that are not routed: {unrouted:?}"
);
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
#[tokio::test(flavor = "multi_thread")]
async fn every_documented_route_is_wired_on_the_live_server() {
let port = free_port();
let args = ServeArgs {
listen: format!("127.0.0.1:{port}"),
auth_token: None,
auth_config: None,
no_auth: true,
max_concurrent_runs: Some(2),
max_queued_runs: Some(8),
default_config: None,
history: None,
cors_origin: vec![],
body_limit_bytes: 1_048_576,
shutdown_grace_secs: 5,
retain_terminal_runs_secs: 604_800,
idempotency_retention_secs: 86_400,
lease_ttl_secs: 30,
probe_timeout_secs: 5,
env_file: None,
no_env_file: true,
no_ui: false,
cluster: false,
cluster_poll_secs: 2,
cluster_max_attempts: 3,
triggers: None,
};
let mut config = ServeConfig::from_args(args).unwrap();
config.log_level = "warn".into();
tokio::spawn(async move {
let _ = faucet_cli::serve::run_server(config).await;
});
let client = reqwest::Client::new();
let base = format!("http://127.0.0.1:{port}");
let mut up = false;
for _ in 0..200 {
if client
.get(format!("{base}/healthz"))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
{
up = true;
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
assert!(up, "server did not come up");
#[cfg(not(feature = "triggers"))]
let routes = canonical_routes();
#[cfg(feature = "triggers")]
let routes: BTreeSet<(String, String)> = canonical_routes()
.into_iter()
.filter(|(_, p)| !p.starts_with("/v1/triggers"))
.collect();
for (method, template) in &routes {
let path = template.replace("{id}", "probe-missing-run");
let url = format!("{base}{path}");
let m: reqwest::Method = method.parse().unwrap();
let mut req = client.request(m, &url);
if *method == "POST" {
req = req.json(&serde_json::json!({}));
}
let resp = req
.send()
.await
.unwrap_or_else(|e| panic!("{method} {path}: {e}"));
let status = resp.status().as_u16();
assert_ne!(
status, 405,
"{method} {template} returned 405 — method not routed on this path"
);
if status == 404 {
let body = resp.text().await.unwrap_or_default();
assert!(
body.contains("\"error\""),
"{method} {template} returned a bare 404 (route not wired); body: {body:?}"
);
}
}
}