use serde_json::Value;
use crate::error::CoreError;
pub(crate) async fn classify(
resp: reqwest::Response,
url: &str,
api_call: bool,
) -> Result<reqwest::Response, CoreError> {
use reqwest::StatusCode as S;
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
if status.is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if location.contains("/welcome") {
return Err(CoreError::GatewayNotCommissioned {
endpoint: Some(url.to_string()),
});
}
return Err(CoreError::Auth {
status: status.as_u16(),
endpoint: Some(url.to_string()),
});
}
match status {
S::FORBIDDEN if is_eam_url(url) => {
let body = resp.text().await.unwrap_or_default();
if body.contains("configured as a controller") {
return Err(CoreError::EamNotController {
endpoint: Some(url.to_string()),
});
}
Err(CoreError::Auth {
status: status.as_u16(),
endpoint: Some(url.to_string()),
})
}
S::UNAUTHORIZED | S::FORBIDDEN => Err(CoreError::Auth {
status: status.as_u16(),
endpoint: Some(url.to_string()),
}),
S::SERVICE_UNAVAILABLE => Err(CoreError::GatewayRestarting {
endpoint: Some(url.to_string()),
}),
S::NOT_FOUND => Err(CoreError::NotFound {
endpoint: Some(url.to_string()),
}),
S::CONFLICT if is_designer_prune_url(url) => Err(CoreError::SessionNotPrunable {
id: designer_prune_id(url),
endpoint: Some(url.to_string()),
}),
S::CONFLICT if is_eam_force_url(url) => {
let body = resp.text().await.unwrap_or_default();
let detail = html_error_parts(&body)
.map(|(_, message)| message)
.filter(|message| !message.is_empty())
.unwrap_or_else(|| {
"the previous '(forced)' run must be completed or deleted first".to_string()
});
Err(CoreError::EamTaskInFlight {
task: eam_force_task_name(url),
detail,
endpoint: Some(url.to_string()),
})
}
S::UNPROCESSABLE_ENTITY if is_config_resource_url(url) => {
let body = resp.text().await.unwrap_or_default();
let joined = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|parsed| {
parsed["messages"].as_array().map(|messages| {
messages
.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join("; ")
})
})
.filter(|joined| !joined.is_empty());
let reason = match joined {
Some(joined) => {
format!("gateway rejected the resource body (HTTP 422 from {url}): {joined}")
}
None if !body.trim().is_empty() => {
format!("gateway rejected the resource body (HTTP 422 from {url}): {body}")
}
None => format!("gateway rejected the resource body (HTTP 422 from {url})"),
};
Err(CoreError::InvalidInput { reason })
}
_ => {
if api_call && status.is_client_error() {
let body = resp.text().await.unwrap_or_default();
return Err(CoreError::GatewayClientError {
status: status.as_u16(),
endpoint: url.to_string(),
body: crate::error::truncate_api_body(&body),
});
}
let is_html = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_some_and(|ct| ct.to_ascii_lowercase().contains("text/html"));
let detail = if is_html {
let body = resp.text().await.unwrap_or_default();
html_error_parts(&body).map(|(code, message)| {
format!(" (gateway error page: Error {code}: {message})")
})
} else {
None
};
Err(CoreError::Internal(format!(
"unexpected HTTP {status} from {url}{}",
detail.unwrap_or_default()
)))
}
}
}
fn is_eam_url(url: &str) -> bool {
url.contains("/data/eam/")
}
fn is_config_resource_url(url: &str) -> bool {
url.contains("/data/api/v1/resources/")
}
fn is_eam_force_url(url: &str) -> bool {
url.contains("/data/eam/api/v1/eam-tasks/force/")
}
fn eam_force_task_name(url: &str) -> String {
url.split_once("/data/eam/api/v1/eam-tasks/force/")
.map(|(_, tail)| {
tail.split('?')
.next()
.unwrap_or_default()
.rsplit('/')
.next()
.unwrap_or_default()
.to_string()
})
.unwrap_or_default()
}
fn is_designer_prune_url(url: &str) -> bool {
url.contains("/data/api/v1/designer/")
}
fn designer_prune_id(url: &str) -> String {
url.split_once("/data/api/v1/designer/")
.map(|(_, tail)| {
tail.split('?')
.next()
.unwrap_or_default()
.trim_end_matches('/')
.to_string()
})
.unwrap_or_default()
}
fn html_error_parts(body: &str) -> Option<(u16, String)> {
const TITLE_ANCHOR: &str = "<title>Error ";
const TITLE_END: &str = "</title>";
const MESSAGE_ANCHOR: &str = "<th>MESSAGE:</th><td>";
const MESSAGE_END: &str = "</td>";
let title_start = body.find(TITLE_ANCHOR)? + TITLE_ANCHOR.len();
let title_end = title_start + body[title_start..].find(TITLE_END)?;
let code: u16 = body[title_start..title_end].trim().parse().ok()?;
let message = body
.find(MESSAGE_ANCHOR)
.map(|start| {
let start = start + MESSAGE_ANCHOR.len();
let end = body[start..]
.find(MESSAGE_END)
.map_or(body.len(), |relative| start + relative);
body[start..end].to_string()
})
.unwrap_or_default();
Some((code, message))
}
#[cfg(test)]
mod tests {
use super::{
designer_prune_id, eam_force_task_name, html_error_parts, is_config_resource_url,
is_designer_prune_url, is_eam_force_url, is_eam_url,
};
#[test]
fn config_resource_url_detection_scopes_the_422_arm() {
assert!(is_config_resource_url(
"http://gw:8088/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks"
));
assert!(is_config_resource_url(
"http://gw:8088/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks"
));
assert!(!is_config_resource_url(
"http://gw:8088/data/eam/api/v1/eam-tasks/history"
));
assert!(!is_config_resource_url(
"http://gw:8088/data/api/v1/gateway-info"
));
}
#[test]
fn eam_url_detection_is_the_runtime_prefix() {
assert!(is_eam_url(
"http://gw:8088/data/eam/api/v1/eam-tasks/history"
));
assert!(is_eam_url(
"http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/t1"
));
assert!(!is_eam_url(
"http://gw:8088/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks"
));
assert!(!is_eam_url("http://gw:8088/data/api/v1/gateway-info"));
}
const CAPTURED_401_HTML: &str = r#"<html><head><meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/><title>Error 401</title></head><body><h2>HTTP ERROR 401 Unauthorized</h2><table><tr><th>URI:</th><td>/data/api/v1/gateway-info</td></tr><tr><th>STATUS:</th><td>401</td></tr><tr><th>MESSAGE:</th><td>Unauthorized</td></tr></table></body></html>"#;
const CAPTURED_401_HTML_RAW: &str = "<html>\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html;charset=ISO-8859-1\"/>\n<title>Error 401</title>\n</head>\n<body><h2>HTTP ERROR 401 Unauthorized</h2>\n<table>\n<tr><th>URI:</th><td>/data/api/v1/gateway-info</td></tr>\n<tr><th>STATUS:</th><td>401</td></tr>\n<tr><th>MESSAGE:</th><td>Unauthorized</td></tr>\n</table>\n\n</body>\n</html>\n";
const CAPTURED_500_HTML: &str = r#"<html><head><meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/><title>Error 500</title></head><body><h2>HTTP ERROR 500 Server Error</h2><table><tr><th>URI:</th><td>/data/api/v1/gateway-info</td></tr><tr><th>STATUS:</th><td>500</td></tr><tr><th>MESSAGE:</th><td>Server Error</td></tr></table></body></html>"#;
#[test]
fn sniffs_the_captured_jetty_401_page() {
assert_eq!(
html_error_parts(CAPTURED_401_HTML),
Some((401, "Unauthorized".to_string()))
);
}
#[test]
fn sniffs_the_raw_wire_capture_with_newlines() {
assert_eq!(
html_error_parts(CAPTURED_401_HTML_RAW),
Some((401, "Unauthorized".to_string()))
);
}
#[test]
fn sniffs_the_500_template_too() {
assert_eq!(
html_error_parts(CAPTURED_500_HTML),
Some((500, "Server Error".to_string()))
);
}
#[test]
fn returns_none_for_non_template_bodies() {
assert_eq!(html_error_parts("<html><body>welcome</body></html>"), None);
assert_eq!(html_error_parts(""), None);
assert_eq!(html_error_parts("{\"message\":\"json\"}"), None);
}
#[test]
fn designer_prune_route_detection_is_exact() {
assert!(is_designer_prune_url(
"http://gw:8088/data/api/v1/designer/d-live-1"
));
assert!(
!is_designer_prune_url("http://gw:8088/data/api/v1/designers"),
"the plural list route must NOT match"
);
assert!(
!is_designer_prune_url("http://gw:8088/data/api/v1/designers?limit=1"),
"the list route with query params must NOT match"
);
assert!(!is_designer_prune_url(
"http://gw:8088/data/perspective/api/v1/sessions"
));
}
#[test]
fn designer_prune_id_extracts_the_trailing_segment() {
assert_eq!(
designer_prune_id("http://gw:8088/data/api/v1/designer/d-live-1"),
"d-live-1"
);
assert_eq!(
designer_prune_id("http://gw:8088/data/api/v1/designer/10443A91?x=1"),
"10443A91"
);
}
#[test]
fn eam_force_url_detection_scopes_the_409_arm() {
assert!(is_eam_force_url(
"http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
));
assert!(!is_eam_force_url(
"http://gw:8088/data/eam/api/v1/eam-tasks/history"
));
assert!(!is_eam_force_url(
"http://gw:8088/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks"
));
}
#[test]
fn eam_force_task_name_extracts_the_trailing_segment() {
assert_eq!(
eam_force_task_name(
"http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
),
"cli-research-backup"
);
assert_eq!(
eam_force_task_name(
"http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/nightly-backup?x=1"
),
"nightly-backup"
);
}
}