use super::funejson::Value; fn error_message(error: &str) -> &'static str { match error { "timeout" => "timed out", "dns" => "DNS lookup failed", "connection" => "connection failed", "tls" => "TLS handshake failed", "protocol" => "protocol error", other => panic!("unknown probe error: {}", other), } } fn expected_text(statuses: Option<&[i64]>) -> String { match statuses { None => "2xx".to_string(), Some([one]) => one.to_string(), Some(list) => { let head: Vec = list[..list.len() - 1].iter().map(|s| s.to_string()).collect(); format!("{} or {}", head.join(", "), list[list.len() - 1]) } } } fn threshold(name: &str, v: Option) { if let Some(t) = v { if t < 1 { panic!("{} must be a whole number 1 or more, received {}", name, t); } } } fn outcome(status: &str, reason: &str, message: String) -> CheckOutcome { CheckOutcome { status: status.to_string(), reason: reason.to_string(), message } } /// One probe, one verdict, checked in a fixed order so the reason is the most /// fundamental thing wrong: a transport error, then the status code, then the /// body, then latency. A 503 that also took ten seconds is reported as the /// 503, because that is what someone has to fix. /// /// # Panics /// Panics on a probe with neither status nor error, out-of-range numbers, or /// inconsistent thresholds. pub fn classify_http_check(probe: &HttpProbe, expect: &HttpExpectation) -> CheckOutcome { if let Some(code) = probe.status_code { if !(100..=599).contains(&code) { panic!("statusCode must be 100 to 599, received {}", code); } } let error_text = probe.error.as_deref().map(error_message); if probe.status_code.is_none() && probe.error.is_none() { panic!("probe must have a statusCode or an error"); } if let Some(l) = probe.latency_ms { if l < 0 { panic!("latencyMs must be a whole number 0 or more, received {}", l); } } if let Some(list) = &expect.statuses { if list.is_empty() { panic!("statuses must not be empty; use null to accept any 2xx"); } for s in list { if !(100..=599).contains(s) { panic!("expected statuses must be 100 to 599, received {}", s); } } } let (degraded, down) = (expect.degraded_latency_ms, expect.down_latency_ms); threshold("degradedLatencyMs", degraded); threshold("downLatencyMs", down); if let (Some(dg), Some(dn)) = (degraded, down) { if dn < dg { panic!("downLatencyMs must not be below degradedLatencyMs: {} < {}", dn, dg); } } if let (Some(error), Some(text)) = (&probe.error, error_text) { return outcome("down", error, text.to_string()); } let code = probe.status_code.unwrap(); let statuses = expect.statuses.as_deref(); let accepted = match statuses { None => (200..=299).contains(&code), Some(list) => list.contains(&code), }; if !accepted { return outcome("down", "unexpected-status", format!("HTTP {}, expected {}", code, expected_text(statuses))); } if let Some(needle) = &expect.body_contains { let found = probe.body.as_deref().map_or(false, |b| b.contains(needle.as_str())); if !found { return outcome("down", "body-mismatch", format!("body does not contain \"{}\"", needle)); } } if let Some(l) = probe.latency_ms { if let Some(dn) = down { if l >= dn { return outcome("down", "too-slow", format!("latency {} ms at or above {} ms", l, dn)); } } if let Some(dg) = degraded { if l >= dg { return outcome("degraded", "slow", format!("latency {} ms at or above {} ms", l, dg)); } } } let message = match probe.latency_ms { None => format!("HTTP {}", code), Some(l) => format!("HTTP {} in {} ms", code, l), }; outcome("up", "ok", message) } /// An optional int field, refusing a fractional number with the same text the /// other languages use for it. fn opt_int(v: &Value, fractional: &dyn Fn(f64) -> String) -> Option { match v { Value::Null => None, Value::Int(i) => Some(*i), Value::Float(f) => panic!("{}", fractional(*f)), _ => panic!("{}", fractional(f64::NAN)), } } fn opt_str(v: &Value) -> Option { if v.is_null() { None } else { Some(v.as_str().to_string()) } } pub fn http_probe_from_value(v: &Value) -> HttpProbe { HttpProbe { status_code: opt_int(&v.get("statusCode"), &|f| format!("statusCode must be 100 to 599, received {}", f)), latency_ms: opt_int(&v.get("latencyMs"), &|f| format!("latencyMs must be a whole number 0 or more, received {}", f)), body: opt_str(&v.get("body")), error: opt_str(&v.get("error")), } } pub fn http_expectation_from_value(v: &Value) -> HttpExpectation { let statuses = v.get("statuses"); HttpExpectation { statuses: if statuses.is_null() { None } else { Some( statuses .as_arr() .iter() .map(|s| opt_int(s, &|f| format!("expected statuses must be 100 to 599, received {}", f)).unwrap()) .collect(), ) }, body_contains: opt_str(&v.get("bodyContains")), degraded_latency_ms: opt_int(&v.get("degradedLatencyMs"), &|f| format!("degradedLatencyMs must be a whole number 1 or more, received {}", f)), down_latency_ms: opt_int(&v.get("downLatencyMs"), &|f| format!("downLatencyMs must be a whole number 1 or more, received {}", f)), } } pub fn check_outcome_to_value(o: &CheckOutcome) -> Value { Value::obj(vec![ ("status", Value::str(&o.status)), ("reason", Value::str(&o.reason)), ("message", Value::str(&o.message)), ]) } pub fn fune_vector(args: &[Value]) -> Value { check_outcome_to_value(&classify_http_check(&http_probe_from_value(&args[0]), &http_expectation_from_value(&args[1]))) }