use crate::ApplicationError;
use crate::RusaintError;
const OZ_DEFAULT_USER: &str = "guest";
const OZ_DEFAULT_PASSWORD: &str = "guest";
const OZ_DEFAULT_HOST: &str = "office.ssu.ac.kr";
pub(crate) struct OzUrlParams {
pub base_url: String,
pub ozrname: String,
pub category: String,
pub params: Vec<(String, String)>,
pub odi_name: String,
}
fn convert_js_hex_escapes(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
if chars.peek() == Some(&'x') {
chars.next(); let mut hex = String::with_capacity(2);
for _ in 0..2 {
if let Some(&c) = chars.peek() {
if c.is_ascii_hexdigit() {
hex.push(c);
chars.next();
} else {
break;
}
}
}
if hex.len() == 2 {
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('\\');
result.push('x');
result.push_str(&hex);
}
} else {
result.push('\\');
result.push('x');
result.push_str(&hex);
}
} else {
result.push(ch);
if let Some(&next) = chars.peek() {
result.push(next);
chars.next();
}
}
} else {
result.push(ch);
}
}
result
}
pub(crate) fn extract_oz_url_from_script_calls(
script_calls: &[String],
) -> Result<String, RusaintError> {
let oz_url_raw = script_calls
.iter()
.find(|call| call.contains("openExternalWindow"))
.and_then(|call| {
tracing::debug!("Found openExternalWindow script_call: {}", call);
let json_start = call.find('{')?;
let json_end = call.rfind('}')?;
let json_str = &call[json_start..=json_end];
let json_str_converted = convert_js_hex_escapes(json_str);
let parsed: serde_json::Value = serde_json::from_str(&json_str_converted).ok()?;
parsed["url"].as_str().map(|s| s.to_string())
})
.ok_or_else(|| {
ApplicationError::OzDataFetchError(format!(
"No openExternalWindow URL found in script_calls: {:?}",
script_calls
))
})?;
tracing::debug!("Parsed OZ URL: {}", oz_url_raw);
if oz_url_raw.starts_with("http://") || oz_url_raw.starts_with("https://") {
Ok(oz_url_raw)
} else {
let separator = if oz_url_raw.starts_with('/') { "" } else { "/" };
Ok(format!(
"https://{}{}{}",
OZ_DEFAULT_HOST, separator, oz_url_raw
))
}
}
pub(crate) fn parse_oz_url_params(oz_url: &str) -> Result<OzUrlParams, RusaintError> {
let parsed_url = url::Url::parse(oz_url).map_err(|e| {
ApplicationError::OzDataFetchError(format!("Failed to parse OZ URL '{}': {}", oz_url, e))
})?;
let base_url = format!(
"{}://{}/oz70",
parsed_url.scheme(),
parsed_url.host_str().unwrap_or(OZ_DEFAULT_HOST)
);
let query_pairs: std::collections::HashMap<String, String> = parsed_url
.query_pairs()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
tracing::debug!("Query pairs: {:?}", query_pairs);
let ozrname = query_pairs.get("ozrname").cloned().ok_or_else(|| {
ApplicationError::OzDataFetchError(format!(
"Missing ozrname in URL: {} (query_pairs: {:?})",
oz_url, query_pairs
))
})?;
let category = query_pairs.get("category").cloned().ok_or_else(|| {
ApplicationError::OzDataFetchError(format!("Missing category in URL: {}", oz_url))
})?;
let p_names_str = query_pairs.get("pName").cloned().ok_or_else(|| {
ApplicationError::OzDataFetchError(format!("Missing pName in URL: {}", oz_url))
})?;
let p_values_str = query_pairs.get("pValue").cloned().ok_or_else(|| {
ApplicationError::OzDataFetchError(format!("Missing pValue in URL: {}", oz_url))
})?;
let p_names: Vec<&str> = p_names_str.split(',').collect();
let p_values: Vec<&str> = p_values_str.split(',').collect();
if p_names.len() != p_values.len() {
return Err(ApplicationError::OzDataFetchError(format!(
"pName count ({}) does not match pValue count ({}) in URL: {}",
p_names.len(),
p_values.len(),
oz_url
))
.into());
}
let params: Vec<(String, String)> = p_names
.iter()
.zip(p_values.iter())
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect();
let odi_name = format!("{}.odi", ozrname);
Ok(OzUrlParams {
base_url,
ozrname,
category,
params,
odi_name,
})
}
pub(crate) async fn fetch_data_module(
oz_params: &OzUrlParams,
) -> Result<ozra::types::DataModuleResponse, RusaintError> {
tracing::debug!(
"OZ params: base_url={}, ozrname={}, category={}, odi={}, params={:?}",
oz_params.base_url,
oz_params.ozrname,
oz_params.category,
oz_params.odi_name,
oz_params.params
);
let oz_client =
ozra::client::OzClient::new(&oz_params.base_url, OZ_DEFAULT_USER, OZ_DEFAULT_PASSWORD)
.map_err(|e| {
ApplicationError::OzDataFetchError(format!("OzClient creation failed: {}", e))
})?;
oz_client
.init_session_with_params(&oz_params.ozrname, &oz_params.category, &oz_params.params)
.await
.map_err(|e| {
ApplicationError::OzDataFetchError(format!("OZ session init failed: {}", e))
})?;
oz_client
.login()
.await
.map_err(|e| ApplicationError::OzDataFetchError(format!("OZ login failed: {}", e)))?;
let response = oz_client
.fetch_data_module(
&oz_params.odi_name,
&format!("/{}", oz_params.category),
&oz_params.params,
)
.await
.map_err(|e| ApplicationError::OzDataFetchError(format!("OZ data fetch failed: {}", e)))?;
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_escape_basic() {
assert_eq!(convert_js_hex_escapes(r"\x41"), "A");
assert_eq!(convert_js_hex_escapes(r"\x42"), "B");
assert_eq!(convert_js_hex_escapes(r"\x61"), "a");
}
#[test]
fn hex_escape_consecutive() {
assert_eq!(convert_js_hex_escapes(r"\x41\x42\x43"), "ABC");
}
#[test]
fn hex_escape_mixed_with_plain_text() {
assert_eq!(convert_js_hex_escapes(r"hello\x20world"), "hello world");
}
#[test]
fn hex_escape_incomplete_single_digit() {
assert_eq!(convert_js_hex_escapes(r"\x4"), "\\x4");
}
#[test]
fn hex_escape_non_hex_digits() {
assert_eq!(convert_js_hex_escapes(r"\xGG"), "\\xGG");
}
#[test]
fn hex_escape_empty_string() {
assert_eq!(convert_js_hex_escapes(""), "");
}
#[test]
fn hex_escape_no_escapes() {
assert_eq!(
convert_js_hex_escapes("plain text without escapes"),
"plain text without escapes"
);
}
#[test]
fn hex_escape_backslash_backslash() {
assert_eq!(convert_js_hex_escapes(r"\\"), "\\\\");
}
#[test]
fn hex_escape_other_escape_sequences_preserved() {
assert_eq!(convert_js_hex_escapes(r"\n"), "\\n");
assert_eq!(convert_js_hex_escapes(r"\t"), "\\t");
}
#[test]
fn hex_escape_url_encoding() {
assert_eq!(
convert_js_hex_escapes(r"key\x3dvalue\x26other\x3d123"),
"key=value&other=123"
);
}
#[test]
fn extract_url_from_script_calls_absolute_url() {
let calls = vec![
"some.other.call()".to_string(),
r#"application.exec("openExternalWindow",{"url":"https://office.ssu.ac.kr/oz70/viewer?ozrname=test&category=cat","title":"Test"})"#.to_string(),
];
let result = extract_oz_url_from_script_calls(&calls).unwrap();
assert_eq!(
result,
"https://office.ssu.ac.kr/oz70/viewer?ozrname=test&category=cat"
);
}
#[test]
fn extract_url_from_script_calls_relative_url_with_slash() {
let calls = vec![
r#"application.exec("openExternalWindow",{"url":"/oz70/viewer?ozrname=test&category=cat"})"#.to_string(),
];
let result = extract_oz_url_from_script_calls(&calls).unwrap();
assert_eq!(
result,
"https://office.ssu.ac.kr/oz70/viewer?ozrname=test&category=cat"
);
}
#[test]
fn extract_url_from_script_calls_relative_url_without_slash() {
let calls = vec![
r#"application.exec("openExternalWindow",{"url":"oz70/viewer?ozrname=test&category=cat"})"#.to_string(),
];
let result = extract_oz_url_from_script_calls(&calls).unwrap();
assert_eq!(
result,
"https://office.ssu.ac.kr/oz70/viewer?ozrname=test&category=cat"
);
}
#[test]
fn extract_url_from_script_calls_with_hex_escapes() {
let calls = vec![
r#"application.exec("openExternalWindow",{"url":"https\x3a\x2f\x2foffice.ssu.ac.kr/path?q\x3dval"})"#.to_string(),
];
let result = extract_oz_url_from_script_calls(&calls).unwrap();
assert_eq!(result, "https://office.ssu.ac.kr/path?q=val");
}
#[test]
fn extract_url_from_script_calls_no_match() {
let calls = vec!["unrelated.call()".to_string()];
let result = extract_oz_url_from_script_calls(&calls);
assert!(result.is_err());
}
#[test]
fn extract_url_from_script_calls_empty() {
let calls: Vec<String> = vec![];
let result = extract_oz_url_from_script_calls(&calls);
assert!(result.is_err());
}
#[test]
fn parse_params_basic() {
let url = "https://office.ssu.ac.kr/oz70/nview5/data/viewer7.jsp?ozrname=myreport&category=mycat&pName=A,B,C&pValue=1,2,3";
let params = parse_oz_url_params(url).unwrap();
assert_eq!(params.base_url, "https://office.ssu.ac.kr/oz70");
assert_eq!(params.ozrname, "myreport");
assert_eq!(params.category, "mycat");
assert_eq!(params.odi_name, "myreport.odi");
assert_eq!(
params.params,
vec![
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
("C".to_string(), "3".to_string()),
]
);
}
#[test]
fn parse_params_preserves_all_params() {
let url = "https://office.ssu.ac.kr/oz70/viewer?ozrname=r&category=c&pName=P_RANDOM,KEY,UNAME&pValue=12345,val,user123";
let params = parse_oz_url_params(url).unwrap();
assert_eq!(
params.params,
vec![
("P_RANDOM".to_string(), "12345".to_string()),
("KEY".to_string(), "val".to_string()),
("UNAME".to_string(), "user123".to_string()),
]
);
}
#[test]
fn parse_params_mismatched_pname_pvalue_count() {
let url =
"https://office.ssu.ac.kr/oz70/viewer?ozrname=r&category=c&pName=A,B,C&pValue=1,2";
let result = parse_oz_url_params(url);
assert!(result.is_err());
}
#[test]
fn parse_params_missing_ozrname() {
let url = "https://office.ssu.ac.kr/oz70/viewer?category=c&pName=A&pValue=1";
let result = parse_oz_url_params(url);
assert!(result.is_err());
}
#[test]
fn parse_params_missing_category() {
let url = "https://office.ssu.ac.kr/oz70/viewer?ozrname=r&pName=A&pValue=1";
let result = parse_oz_url_params(url);
assert!(result.is_err());
}
#[test]
fn parse_params_invalid_url() {
let result = parse_oz_url_params("not a valid url");
assert!(result.is_err());
}
}