use std::time::{SystemTime, UNIX_EPOCH};
use percent_encoding::utf8_percent_encode;
use crate::parsers::common::{RequestBody, SerializeToWeb, ToRequestBody, CHARACTERS_TO_ENCODE};
use crate::parsers::constants::EXPLORE_URL;
use crate::requests::config::explore::ExploreConfig;
use anyhow::Result;
pub struct ExploreRequestOptions<'a> {
pub config: &'a ExploreConfig,
pub frontend_version: &'a str,
pub language: &'a str,
pub country: &'a str,
}
impl ToRequestBody for ExploreRequestOptions<'_> {
fn to_request_body(&self) -> Result<RequestBody> {
self.try_into()
}
}
impl TryFrom<&ExploreRequestOptions<'_>> for RequestBody {
type Error = anyhow::Error;
fn try_from(opts: &ExploreRequestOptions<'_>) -> Result<Self> {
let cfg = opts.config;
let epoch_now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let origin_airports = serialize_origin_airports(&cfg.origin);
let routes = if let Some(dest) = &cfg.destination {
let dest_airports = serialize_dest_location(dest);
format!(
r#"[[{origin},{dest},null,0],[{dest},{origin},null,0]]"#,
origin = origin_airports,
dest = dest_airports,
)
} else {
format!(r#"[[{0},[],null,0],[[[],{0},null,0]]]"#, origin_airports)
};
let trip_date = match &cfg.trip_date {
None => "[]".to_string(),
Some(d) => {
format!("[{},{}]", d.month, cfg.trip_duration.as_wire_code())
}
};
let travelers = cfg.travellers.serialize_to_web()?;
let cabin_class = cfg.travel_class as i32;
let price_limit = match cfg.max_price {
None => "null".to_string(),
Some(p) => format!("[null,{}]", p),
};
let alliance = match &cfg.airline_alliance {
None => "null".to_string(),
Some(a) => format!(r#"[\"{}\"]"#, a.as_google_str()),
};
let duration_limit = match cfg.max_flight_duration_minutes {
None => "null".to_string(),
Some(m) => format!("[{}]", m),
};
let baggage = match cfg.baggage {
None => "null".to_string(),
Some((carry_on, checked)) => format!("[{},{}]", carry_on, checked),
};
let map_sw = match &cfg.map_bounds {
None => "null".to_string(),
Some(b) => format!("[{},{}]", b.sw.0, b.sw.1),
};
let map_ne = match &cfg.map_bounds {
None => "null".to_string(),
Some(b) => format!("[{},{}]", b.ne.0, b.ne.1),
};
let trip_type = 2_i32;
let options = if let Some(mid) = &cfg.interest {
format!(
r#"[null,null,{cabin_class},null,{trip_date},1,{travelers},{price_limit},{alliance},{duration_limit},null,{baggage},null,{routes},null,null,null,0,null,null,null,null,null,null,null,null,null,\"{mid}\"]"#
)
} else {
format!(
r#"[null,null,{cabin_class},null,{trip_date},1,{travelers},{price_limit},{alliance},{duration_limit},null,{baggage},null,{routes},null,null,null,0]"#
)
};
let inner = format!(
r#"[[],{map_sw},{map_ne},{options},null,1,null,0,null,1,[1100,719],{trip_type}]"#
);
let body = format!(
r#"f.req=[null,"{}"]&at=AAuQa1qiXfSThbBOCdcDUAVTopoc:{}&"#,
inner, epoch_now
);
let url = format!(
"{EXPLORE_URL}?f.sid=6921237406276106431&bl={version}&hl={lang}-{country}&soc-app=162&soc-platform=1&soc-device=1&_reqid=4150414&rt=c",
version = opts.frontend_version,
lang = opts.language,
country = opts.country.to_uppercase(),
);
let encoded = utf8_percent_encode(&body, CHARACTERS_TO_ENCODE).to_string();
Ok(RequestBody { url, body: encoded })
}
}
fn serialize_dest_location(loc: &crate::parsers::common::Location) -> String {
use crate::parsers::common::PlaceType;
let type_code = match loc.loc_type {
PlaceType::Region => 6,
_ => 0, };
format!(r#"[[[\"{}\",{}]]]"#, loc.loc_identifier, type_code)
}
fn serialize_origin_airports(locations: &[crate::parsers::common::Location]) -> String {
use crate::parsers::common::PlaceType;
if locations.is_empty() {
return "[[]]".to_string();
}
let pairs: Vec<String> = locations
.iter()
.map(|loc| {
let type_code = match loc.loc_type {
PlaceType::Airport | PlaceType::Unspecified => 0,
PlaceType::City => 4,
PlaceType::MaybeRegion | PlaceType::RegionMaybe => 4,
PlaceType::Region => 6,
};
format!(r#"[\"{}\",{}]"#, loc.loc_identifier, type_code)
})
.collect();
format!("[[{}]]", pairs.join(","))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::parsers::common::{Location, PlaceType};
use crate::requests::config::explore::ExploreConfig;
fn make_lux() -> Location {
Location {
loc_identifier: "LUX".to_string(),
loc_type: PlaceType::Airport,
location_name: None,
}
}
#[test]
fn explore_request_produces_url_with_frontend_version() {
let cfg = ExploreConfig {
origin: vec![make_lux()],
..Default::default()
};
let opts = ExploreRequestOptions {
config: &cfg,
frontend_version: "boq_travel-frontend-ui_20240110.02_p0",
language: "en",
country: "GB",
};
let body = opts.to_request_body().unwrap();
assert!(body.url.contains("boq_travel-frontend-ui_20240110.02_p0"));
assert!(body.url.contains("GetExploreDestinations"));
}
#[test]
fn explore_request_contains_origin_iata() {
let cfg = ExploreConfig {
origin: vec![make_lux()],
..Default::default()
};
let opts = ExploreRequestOptions {
config: &cfg,
frontend_version: "test",
language: "en",
country: "GB",
};
let body = opts.to_request_body().unwrap();
assert!(body.body.contains("LUX"), "body should contain LUX");
}
#[test]
fn explore_request_with_max_price_contains_price() {
let cfg = ExploreConfig {
origin: vec![make_lux()],
max_price: Some(300),
..Default::default()
};
let opts = ExploreRequestOptions {
config: &cfg,
frontend_version: "test",
language: "en",
country: "GB",
};
let body = opts.to_request_body().unwrap();
assert!(body.body.contains("300"), "body should contain max price");
}
}